zakat
xxx

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

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

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

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

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

_vault (dict):
    - account (dict):
        - {account_number} (dict):
            - balance (int): The current balance of the account.
            - box (dict): A dictionary storing transaction details.
                - {timestamp} (dict):
                    - capital (int): The initial amount of the transaction.
                    - count (int): The number of times Zakat has been calculated for this transaction.
                    - last (int): The timestamp of the last Zakat calculation.
                    - rest (int): The remaining amount after Zakat deductions and withdrawal.
                    - total (int): The total Zakat deducted from this transaction.
            - count (int): The total number of transactions for the account.
            - log (dict): A dictionary storing transaction logs.
                - {timestamp} (dict):
                    - value (int): The transaction amount (positive or negative).
                    - desc (str): The description of the transaction.
                    - ref (int): The box reference (positive or None).
                    - file (dict): A dictionary storing file references associated with the transaction.
            - hide (bool): Indicates whether the account is hidden or not.
            - zakatable (bool): Indicates whether the account is subject to Zakat.
    - exchange (dict):
        - account (dict):
            - {timestamps} (dict):
                - rate (float): Exchange rate when compared to local currency.
                - description (str): The description of the exchange rate.
    - history (dict):
        - {timestamp} (list): A list of dictionaries storing the history of actions performed.
            - {action_dict} (dict):
                - action (Action): The type of action (CREATE, TRACK, LOG, SUB, ADD_FILE, REMOVE_FILE, BOX_TRANSFER, EXCHANGE, REPORT, ZAKAT).
                - account (str): The account number associated with the action.
                - ref (int): The reference number of the transaction.
                - file (int): The reference number of the file (if applicable).
                - key (str): The key associated with the action (e.g., 'rest', 'total').
                - value (int): The value associated with the action.
                - math (MathOperation): The mathematical operation performed (if applicable).
    - lock (int or None): The timestamp indicating the current lock status (None if not locked).
    - report (dict):
        - {timestamp} (tuple): A tuple storing Zakat report details.
ZakatTracker(db_path: str = './zakat_db/zakat.camel', history_mode: bool = True)
270    def __init__(self, db_path: str = "./zakat_db/zakat.camel", history_mode: bool = True):
271        """
272        Initialize ZakatTracker with database path and history mode.
273
274        Parameters:
275        db_path (str): The path to the database file. Default is "zakat.camel".
276        history_mode (bool): The mode for tracking history. Default is True.
277
278        Returns:
279        None
280        """
281        self._base_path = None
282        self._vault_path = None
283        self._vault = None
284        self.reset()
285        self._history(history_mode)
286        self.path(db_path)

Initialize ZakatTracker with database path and history mode.

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

Returns: None

@staticmethod
def Version() -> str:
194    @staticmethod
195    def Version() -> str:
196        """
197        Returns the current version of the software.
198
199        This function returns a string representing the current version of the software,
200        including major, minor, and patch version numbers in the format "X.Y.Z".
201
202        Returns:
203        str: The current version of the software.
204        """
205        return '0.2.91'

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:
207    @staticmethod
208    def ZakatCut(x: float) -> float:
209        """
210        Calculates the Zakat amount due on an asset.
211
212        This function calculates the zakat amount due on a given asset value over one lunar year.
213        Zakat is an Islamic obligatory alms-giving, calculated as a fixed percentage of an individual's wealth
214        that exceeds a certain threshold (Nisab).
215
216        Parameters:
217        x: The total value of the asset on which Zakat is to be calculated.
218
219        Returns:
220        The amount of Zakat due on the asset, calculated as 2.5% of the asset's value.
221        """
222        return 0.025 * x  # Zakat Cut in one Lunar Year

Calculates the Zakat amount due on an asset.

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

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

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

@staticmethod
def TimeCycle(days: int = 355) -> int:
224    @staticmethod
225    def TimeCycle(days: int = 355) -> int:
226        """
227        Calculates the approximate duration of a lunar year in nanoseconds.
228
229        This function calculates the approximate duration of a lunar year based on the given number of days.
230        It converts the given number of days into nanoseconds for use in high-precision timing applications.
231
232        Parameters:
233        days: The number of days in a lunar year. Defaults to 355,
234              which is an approximation of the average length of a lunar year.
235
236        Returns:
237        The approximate duration of a lunar year in nanoseconds.
238        """
239        return int(60 * 60 * 24 * days * 1e9)  # Lunar Year in nanoseconds

Calculates the approximate duration of a lunar year in nanoseconds.

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

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

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

@staticmethod
def Nisab(gram_price: float, gram_quantity: float = 595) -> float:
241    @staticmethod
242    def Nisab(gram_price: float, gram_quantity: float = 595) -> float:
243        """
244        Calculate the total value of Nisab (a unit of weight in Islamic jurisprudence) based on the given price per gram.
245
246        This function calculates the Nisab value, which is the minimum threshold of wealth,
247        that makes an individual liable for paying Zakat.
248        The Nisab value is determined by the equivalent value of a specific amount
249        of gold or silver (currently 595 grams in silver) in the local currency.
250
251        Parameters:
252        - gram_price (float): The price per gram of Nisab.
253        - gram_quantity (float): The quantity of grams in a Nisab. Default is 595 grams of silver.
254
255        Returns:
256        - float: The total value of Nisab based on the given price per gram.
257        """
258        return gram_price * gram_quantity

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

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

Parameters:

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

Returns:

  • float: The total value of Nisab based on the given price per gram.
@staticmethod
def ext() -> str:
260    @staticmethod
261    def ext() -> str:
262        """
263        Returns the file extension used by the ZakatTracker class.
264
265        Returns:
266        str: The file extension used by the ZakatTracker class, which is 'camel'.
267        """
268        return 'camel'

Returns the file extension used by the ZakatTracker class.

Returns: str: The file extension used by the ZakatTracker class, which is 'camel'.

def path(self, path: str = None) -> str:
288    def path(self, path: str = None) -> str:
289        """
290        Set or get the path to the database file.
291
292        If no path is provided, the current path is returned.
293        If a path is provided, it is set as the new path.
294        The function also creates the necessary directories if the provided path is a file.
295
296        Parameters:
297        path (str): The new path to the database file. If not provided, the current path is returned.
298
299        Returns:
300        str: The current or new path to the database file.
301        """
302        if path is None:
303            return self._vault_path
304        self._vault_path = Path(path).resolve()
305        base_path = Path(path).resolve()
306        if base_path.is_file() or base_path.suffix:
307            base_path = base_path.parent
308        base_path.mkdir(parents=True, exist_ok=True)
309        self._base_path = base_path
310        return str(self._vault_path)

Set or get the path to the database file.

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

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

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

def base_path(self, *args) -> str:
312    def base_path(self, *args) -> str:
313        """
314        Generate a base path by joining the provided arguments with the existing base path.
315
316        Parameters:
317        *args (str): Variable length argument list of strings to be joined with the base path.
318
319        Returns:
320        str: The generated base path. If no arguments are provided, the existing base path is returned.
321        """
322        if not args:
323            return str(self._base_path)
324        filtered_args = []
325        ignored_filename = None
326        for arg in args:
327            if Path(arg).suffix:
328                ignored_filename = arg
329            else:
330                filtered_args.append(arg)
331        base_path = Path(self._base_path)
332        full_path = base_path.joinpath(*filtered_args)
333        full_path.mkdir(parents=True, exist_ok=True)
334        if ignored_filename is not None:
335            return full_path.resolve() / ignored_filename  # Join with the ignored filename
336        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:
338    @staticmethod
339    def scale(x: float | int | Decimal, decimal_places: int = 2) -> int:
340        """
341        Scales a numerical value by a specified power of 10, returning an integer.
342
343        This function is designed to handle various numeric types (`float`, `int`, or `Decimal`) and
344        facilitate precise scaling operations, particularly useful in financial or scientific calculations.
345
346        Parameters:
347        x: The numeric value to scale. Can be a floating-point number, integer, or decimal.
348        decimal_places: The exponent for the scaling factor (10**y). Defaults to 2, meaning the input is scaled
349            by a factor of 100 (e.g., converts 1.23 to 123).
350
351        Returns:
352        The scaled value, rounded to the nearest integer.
353
354        Raises:
355        TypeError: If the input `x` is not a valid numeric type.
356
357        Examples:
358        >>> ZakatTracker.scale(3.14159)
359        314
360        >>> ZakatTracker.scale(1234, decimal_places=3)
361        1234000
362        >>> ZakatTracker.scale(Decimal("0.005"), decimal_places=4)
363        50
364        """
365        if not isinstance(x, (float, int, Decimal)):
366            raise TypeError("Input 'x' must be a float, int, or Decimal.")
367        return int(Decimal(f"{x:.{decimal_places}f}") * (10 ** decimal_places))

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

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

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

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

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

Examples:

>>> ZakatTracker.scale(3.14159)
314
>>> ZakatTracker.scale(1234, decimal_places=3)
1234000
>>> ZakatTracker.scale(Decimal("0.005"), decimal_places=4)
50
@staticmethod
def unscale( x: int, return_type: type = <class 'float'>, decimal_places: int = 2) -> float | decimal.Decimal:
369    @staticmethod
370    def unscale(x: int, return_type: type = float, decimal_places: int = 2) -> float | Decimal:
371        """
372        Unscales an integer by a power of 10.
373
374        Parameters:
375        x: The integer to unscale.
376        return_type: The desired type for the returned value. Can be float, int, or Decimal. Defaults to float.
377        decimal_places: The power of 10 to use. Defaults to 2.
378
379        Returns:
380        The unscaled number, converted to the specified return_type.
381
382        Raises:
383        TypeError: If the return_type is not float or Decimal.
384        """
385        if return_type not in (float, Decimal):
386            raise TypeError(f'Invalid return_type({return_type}). Supported types are float, int, and Decimal.')
387        return round(return_type(x / (10 ** decimal_places)), decimal_places)

Unscales an integer by a power of 10.

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

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

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

def reset(self) -> None:
403    def reset(self) -> None:
404        """
405        Reset the internal data structure to its initial state.
406
407        Parameters:
408        None
409
410        Returns:
411        None
412        """
413        self._vault = {
414            'account': {},
415            'exchange': {},
416            'history': {},
417            'lock': None,
418            'report': {},
419        }

Reset the internal data structure to its initial state.

Parameters: None

Returns: None

@staticmethod
def time( now: <module 'datetime' from '/opt/hostedtoolcache/Python/3.12.10/x64/lib/python3.12/datetime.py'> = None) -> int:
421    @staticmethod
422    def time(now: datetime = None) -> int:
423        """
424        Generates a timestamp based on the provided datetime object or the current datetime.
425
426        Parameters:
427        now (datetime, optional): The datetime object to generate the timestamp from.
428        If not provided, the current datetime is used.
429
430        Returns:
431        int: The timestamp in positive nanoseconds since the Unix epoch (January 1, 1970),
432            before 1970 will return in negative until 1000AD.
433        """
434        if now is None:
435            now = datetime.datetime.now()
436        ordinal_day = now.toordinal()
437        ns_in_day = (now - now.replace(hour=0, minute=0, second=0, microsecond=0)).total_seconds() * 10 ** 9
438        return int((ordinal_day - 719_163) * 86_400_000_000_000 + ns_in_day)

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

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

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

@staticmethod
def time_to_datetime( ordinal_ns: int) -> <module 'datetime' from '/opt/hostedtoolcache/Python/3.12.10/x64/lib/python3.12/datetime.py'>:
440    @staticmethod
441    def time_to_datetime(ordinal_ns: int) -> datetime:
442        """
443        Converts an ordinal number (number of days since 1000-01-01) to a datetime object.
444
445        Parameters:
446        ordinal_ns (int): The ordinal number of days since 1000-01-01.
447
448        Returns:
449        datetime: The corresponding datetime object.
450        """
451        ordinal_day = ordinal_ns // 86_400_000_000_000 + 719_163
452        ns_in_day = ordinal_ns % 86_400_000_000_000
453        d = datetime.datetime.fromordinal(ordinal_day)
454        t = datetime.timedelta(seconds=ns_in_day // 10 ** 9)
455        return datetime.datetime.combine(d, datetime.time()) + t

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

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

Returns: datetime: The corresponding datetime object.

def clean_history(self, lock: int | None = None) -> int:
457    def clean_history(self, lock: int | None = None) -> int:
458        """
459        Cleans up the history of actions performed on the ZakatTracker instance.
460
461        Parameters:
462        lock (int, optional): The lock ID is used to clean up the empty history.
463            If not provided, it cleans up the empty history records for all locks.
464
465        Returns:
466        int: The number of locks cleaned up.
467        """
468        count = 0
469        if lock in self._vault['history']:
470            if len(self._vault['history'][lock]) <= 0:
471                count += 1
472                del self._vault['history'][lock]
473            return count
474        self.free(self.lock())
475        for lock in self._vault['history']:
476            if len(self._vault['history'][lock]) <= 0:
477                count += 1
478                del self._vault['history'][lock]
479        return count

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

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

Returns: int: The number of locks cleaned up.

def nolock(self) -> bool:
517    def nolock(self) -> bool:
518        """
519        Check if the vault lock is currently not set.
520
521        Returns:
522        bool: True if the vault lock is not set, False otherwise.
523        """
524        return self._vault['lock'] is None

Check if the vault lock is currently not set.

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

def lock(self) -> int:
526    def lock(self) -> int:
527        """
528        Acquires a lock on the ZakatTracker instance.
529
530        Returns:
531        int: The lock ID. This ID can be used to release the lock later.
532        """
533        return self._step()

Acquires a lock on the ZakatTracker instance.

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

def vault(self) -> dict:
535    def vault(self) -> dict:
536        """
537        Returns a copy of the internal vault dictionary.
538
539        This method is used to retrieve the current state of the ZakatTracker object.
540        It provides a snapshot of the internal data structure, allowing for further
541        processing or analysis.
542
543        Returns:
544        dict: A copy of the internal vault dictionary.
545        """
546        return self._vault.copy()

Returns a copy of the internal vault dictionary.

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

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

def stats(self) -> dict[str, tuple]:
548    def stats(self) -> dict[str, tuple]:
549        """
550        Calculates and returns statistics about the object's data storage.
551
552        This method determines the size of the database file on disk and the
553        size of the data currently held in RAM (likely within a dictionary).
554        Both sizes are reported in bytes and in a human-readable format
555        (e.g., KB, MB).
556
557        Returns:
558        dict[str, tuple]: A dictionary containing the following statistics:
559
560            * 'database': A tuple with two elements:
561                - The database file size in bytes (int).
562                - The database file size in human-readable format (str).
563            * 'ram': A tuple with two elements:
564                - The RAM usage (dictionary size) in bytes (int).
565                - The RAM usage in human-readable format (str).
566
567        Example:
568        >>> stats = my_object.stats()
569        >>> print(stats['database'])
570        (256000, '250.0 KB')
571        >>> print(stats['ram'])
572        (12345, '12.1 KB')
573        """
574        ram_size = self.get_dict_size(self.vault())
575        file_size = os.path.getsize(self.path())
576        return {
577            'database': (file_size, self.human_readable_size(file_size)),
578            'ram': (ram_size, self.human_readable_size(ram_size)),
579        }

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

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

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

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

Example:

>>> stats = my_object.stats()
>>> print(stats['database'])
(256000, '250.0 KB')
>>> print(stats['ram'])
(12345, '12.1 KB')
def files(self) -> list[dict[str, str | int]]:
581    def files(self) -> list[dict[str, str | int]]:
582        """
583        Retrieves information about files associated with this class.
584
585        This class method provides a standardized way to gather details about
586        files used by the class for storage, snapshots, and CSV imports.
587
588        Returns:
589        list[dict[str, str | int]]: A list of dictionaries, each containing information
590            about a specific file:
591
592            * type (str): The type of file ('database', 'snapshot', 'import_csv').
593            * path (str): The full file path.
594            * exists (bool): Whether the file exists on the filesystem.
595            * size (int): The file size in bytes (0 if the file doesn't exist).
596            * human_readable_size (str): A human-friendly representation of the file size (e.g., '10 KB', '2.5 MB').
597
598        Example:
599        ```
600        file_info = MyClass.files()
601        for info in file_info:
602            print(f"Type: {info['type']}, Exists: {info['exists']}, Size: {info['human_readable_size']}")
603        ```
604        """
605        result = []
606        for file_type, path in {
607            'database': self.path(),
608            'snapshot': self.snapshot_cache_path(),
609            'import_csv': self.import_csv_cache_path(),
610        }.items():
611            exists = os.path.exists(path)
612            size = os.path.getsize(path) if exists else 0
613            human_readable_size = self.human_readable_size(size) if exists else 0
614            result.append({
615                'type': file_type,
616                'path': path,
617                'exists': exists,
618                'size': size,
619                'human_readable_size': human_readable_size,
620            })
621        return result

Retrieves information about files associated with this class.

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

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

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

Example:

file_info = MyClass.files()
for info in file_info:
    print(f"Type: {info['type']}, Exists: {info['exists']}, Size: {info['human_readable_size']}")
def steps(self) -> dict:
623    def steps(self) -> dict:
624        """
625        Returns a copy of the history of steps taken in the ZakatTracker.
626
627        The history is a dictionary where each key is a unique identifier for a step,
628        and the corresponding value is a dictionary containing information about the step.
629
630        Returns:
631        dict: A copy of the history of steps taken in the ZakatTracker.
632        """
633        return self._vault['history'].copy()

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

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

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

def free(self, lock: int, auto_save: bool = True) -> bool:
635    def free(self, lock: int, auto_save: bool = True) -> bool:
636        """
637        Releases the lock on the database.
638
639        Parameters:
640        lock (int): The lock ID to be released.
641        auto_save (bool): Whether to automatically save the database after releasing the lock.
642
643        Returns:
644        bool: True if the lock is successfully released and (optionally) saved, False otherwise.
645        """
646        if lock == self._vault['lock']:
647            self._vault['lock'] = None
648            self.clean_history(lock)
649            if auto_save:
650                return self.save(self.path())
651            return True
652        return False

Releases the lock on the database.

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

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

def account_exists(self, account) -> bool:
654    def account_exists(self, account) -> bool:
655        """
656        Check if the given account exists in the vault.
657
658        Parameters:
659        account (str): The account number to check.
660
661        Returns:
662        bool: True if the account exists, False otherwise.
663        """
664        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:
666    def box_size(self, account) -> int:
667        """
668        Calculate the size of the box for a specific account.
669
670        Parameters:
671        account (str): The account number for which the box size needs to be calculated.
672
673        Returns:
674        int: The size of the box for the given account. If the account does not exist, -1 is returned.
675        """
676        if self.account_exists(account):
677            return len(self._vault['account'][account]['box'])
678        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:
680    def log_size(self, account) -> int:
681        """
682        Get the size of the log for a specific account.
683
684        Parameters:
685        account (str): The account number for which the log size needs to be calculated.
686
687        Returns:
688        int: The size of the log for the given account. If the account does not exist, -1 is returned.
689        """
690        if self.account_exists(account):
691            return len(self._vault['account'][account]['log'])
692        return -1

Get the size of the log for a specific account.

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

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

@staticmethod
def file_hash(file_path: str, algorithm: str = 'blake2b') -> str:
694    @staticmethod
695    def file_hash(file_path: str, algorithm: str = "blake2b") -> str:
696        """
697        Calculates the hash of a file using the specified algorithm.
698
699        Parameters:
700        file_path (str): The path to the file.
701        algorithm (str, optional): The hashing algorithm to use. Defaults to "blake2b".
702
703        Returns:
704        str: The hexadecimal representation of the file's hash.
705        """
706        hash_obj = hashlib.new(algorithm)  # Create the hash object
707        with open(file_path, "rb") as f:  # Open file in binary mode for reading
708            for chunk in iter(lambda: f.read(4096), b""):  # Read file in chunks
709                hash_obj.update(chunk)
710        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):
712    def snapshot_cache_path(self):
713        """
714        Generate the path for the cache file used to store snapshots.
715
716        The cache file is a camel file that stores the timestamps of the snapshots.
717        The file name is derived from the main database file name by replacing the ".camel" extension with ".snapshots.camel".
718
719        Returns:
720        str: The path to the cache file.
721        """
722        path = str(self.path())
723        ext = self.ext()
724        ext_len = len(ext)
725        if path.endswith(f'.{ext}'):
726            path = path[:-ext_len-1]
727        _, filename = os.path.split(path + f'.snapshots.{ext}')
728        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".

Returns: str: The path to the cache file.

def snapshot(self) -> bool:
730    def snapshot(self) -> bool:
731        """
732        This function creates a snapshot of the current database state.
733
734        The function calculates the hash of the current database file and checks if a snapshot with the same hash already exists.
735        If a snapshot with the same hash exists, the function returns True without creating a new snapshot.
736        If a snapshot with the same hash does not exist, the function creates a new snapshot by saving the current database state
737        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.
738
739        Parameters:
740        None
741
742        Returns:
743        bool: True if a snapshot with the same hash already exists or if the snapshot is successfully created. False if the snapshot creation fails.
744        """
745        current_hash = self.file_hash(self.path())
746        cache: dict[str, int] = {}  # hash: time_ns
747        try:
748            with open(self.snapshot_cache_path(), 'r') as stream:
749                cache = camel.load(stream.read())
750        except:
751            pass
752        if current_hash in cache:
753            return True
754        time = time_ns()
755        cache[current_hash] = time
756        if not self.save(self.base_path('snapshots', f'{time}.{self.ext()}')):
757            return False
758        with open(self.snapshot_cache_path(), 'w') as stream:
759            stream.write(camel.dump(cache))
760        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]]:
762    def snapshots(self, hide_missing: bool = True, verified_hash_only: bool = False) \
763            -> dict[int, tuple[str, str, bool]]:
764        """
765        Retrieve a dictionary of snapshots, with their respective hashes, paths, and existence status.
766
767        Parameters:
768        - hide_missing (bool): If True, only include snapshots that exist in the dictionary. Default is True.
769        - verified_hash_only (bool): If True, only include snapshots with a valid hash. Default is False.
770
771        Returns:
772        - dict[int, tuple[str, str, bool]]: A dictionary where the keys are the timestamps of the snapshots,
773        and the values are tuples containing the snapshot's hash, path, and existence status.
774        """
775        cache: dict[str, int] = {}  # hash: time_ns
776        try:
777            with open(self.snapshot_cache_path(), 'r') as stream:
778                cache = camel.load(stream.read())
779        except:
780            pass
781        if not cache:
782            return {}
783        result: dict[int, tuple[str, str, bool]] = {}  # time_ns: (hash, path, exists)
784        for file_hash, ref in cache.items():
785            path = self.base_path('snapshots', f'{ref}.{self.ext()}')
786            exists = os.path.exists(path)
787            valid_hash = self.file_hash(path) == file_hash if verified_hash_only else True
788            if (verified_hash_only and not valid_hash) or (verified_hash_only and not exists):
789                continue
790            if exists or not hide_missing:
791                result[ref] = (file_hash, path, exists)
792        return result

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

Parameters:

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

Returns:

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

Revert the last operation.

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

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

def ref_exists(self, account: str, ref_type: str, ref: int) -> bool:
942    def ref_exists(self, account: str, ref_type: str, ref: int) -> bool:
943        """
944        Check if a specific reference (transaction) exists in the vault for a given account and reference type.
945
946        Parameters:
947        account (str): The account number for which to check the existence of the reference.
948        ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
949        ref (int): The reference (transaction) number to check for existence.
950
951        Returns:
952        bool: True if the reference exists for the given account and reference type, False otherwise.
953        """
954        if account in self._vault['account']:
955            return ref in self._vault['account'][account][ref_type]
956        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:
958    def box_exists(self, account: str, ref: int) -> bool:
959        """
960        Check if a specific box (transaction) exists in the vault for a given account and reference.
961
962        Parameters:
963        - account (str): The account number for which to check the existence of the box.
964        - ref (int): The reference (transaction) number to check for existence.
965
966        Returns:
967        - bool: True if the box exists for the given account and reference, False otherwise.
968        """
969        return self.ref_exists(account, 'box', ref)

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

Parameters:

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

Returns:

  • bool: True if the box exists for the given account and reference, False otherwise.
def track( self, unscaled_value: float | int | decimal.Decimal = 0, desc: str = '', account: str = 1, logging: bool = True, created: int = None, debug: bool = False) -> int:
 971    def track(self, unscaled_value: float | int | Decimal = 0, desc: str = '', account: str = 1, logging: bool = True,
 972              created: int = None,
 973              debug: bool = False) -> int:
 974        """
 975        This function tracks a transaction for a specific account.
 976
 977        Parameters:
 978        unscaled_value (float | int | Decimal): The value of the transaction. Default is 0.
 979        desc (str): The description of the transaction. Default is an empty string.
 980        account (str): The account for which the transaction is being tracked. Default is '1'.
 981        logging (bool): Whether to log the transaction. Default is True.
 982        created (int): The timestamp of the transaction. If not provided, it will be generated. Default is None.
 983        debug (bool): Whether to print debug information. Default is False.
 984
 985        Returns:
 986        int: The timestamp of the transaction.
 987
 988        This function creates a new account if it doesn't exist, logs the transaction if logging is True, and updates the account's balance and box.
 989
 990        Raises:
 991        ValueError: The log transaction happened again in the same nanosecond time.
 992        ValueError: The box transaction happened again in the same nanosecond time.
 993        """
 994        if debug:
 995            print('track', f'unscaled_value={unscaled_value}, debug={debug}')
 996        if created is None:
 997            created = self.time()
 998        no_lock = self.nolock()
 999        self.lock()
1000        if not self.account_exists(account):
1001            if debug:
1002                print(f"account {account} created")
1003            self._vault['account'][account] = {
1004                'balance': 0,
1005                'box': {},
1006                'count': 0,
1007                'log': {},
1008                'hide': False,
1009                'zakatable': True,
1010            }
1011            self._step(Action.CREATE, account)
1012        if unscaled_value == 0:
1013            if no_lock:
1014                self.free(self.lock())
1015            return 0
1016        value = self.scale(unscaled_value)
1017        if logging:
1018            self._log(value=value, desc=desc, account=account, created=created, ref=None, debug=debug)
1019        if debug:
1020            print('create-box', created)
1021        if self.box_exists(account, created):
1022            raise ValueError(f"The box transaction happened again in the same nanosecond time({created}).")
1023        if debug:
1024            print('created-box', created)
1025        self._vault['account'][account]['box'][created] = {
1026            'capital': value,
1027            'count': 0,
1028            'last': 0,
1029            'rest': value,
1030            'total': 0,
1031        }
1032        self._step(Action.TRACK, account, ref=created, value=value)
1033        if no_lock:
1034            self.free(self.lock())
1035        return created

This function tracks a transaction for a specific account.

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

Returns: int: The timestamp of the transaction.

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

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

def log_exists(self, account: str, ref: int) -> bool:
1037    def log_exists(self, account: str, ref: int) -> bool:
1038        """
1039        Checks if a specific transaction log entry exists for a given account.
1040
1041        Parameters:
1042        account (str): The account number associated with the transaction log.
1043        ref (int): The reference to the transaction log entry.
1044
1045        Returns:
1046        bool: True if the transaction log entry exists, False otherwise.
1047        """
1048        return self.ref_exists(account, 'log', ref)

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

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

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

def exchange( self, account, created: int = None, rate: float = None, description: str = None, debug: bool = False) -> dict:
1096    def exchange(self, account, created: int = None, rate: float = None, description: str = None,
1097                 debug: bool = False) -> dict:
1098        """
1099        This method is used to record or retrieve exchange rates for a specific account.
1100
1101        Parameters:
1102        - account (str): The account number for which the exchange rate is being recorded or retrieved.
1103        - created (int): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
1104        - rate (float): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
1105        - description (str): A description of the exchange rate.
1106        - debug (bool): Whether to print debug information. Default is False.
1107
1108        Returns:
1109        - dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found,
1110        it returns a dictionary with default values for the rate and description.
1111        """
1112        if debug:
1113            print('exchange', f'debug={debug}')
1114        if created is None:
1115            created = self.time()
1116        no_lock = self.nolock()
1117        self.lock()
1118        if rate is not None:
1119            if rate <= 0:
1120                return dict()
1121            if account not in self._vault['exchange']:
1122                self._vault['exchange'][account] = {}
1123            if len(self._vault['exchange'][account]) == 0 and rate <= 1:
1124                return {"time": created, "rate": 1, "description": None}
1125            self._vault['exchange'][account][created] = {"rate": rate, "description": description}
1126            self._step(Action.EXCHANGE, account, ref=created, value=rate)
1127            if no_lock:
1128                self.free(self.lock())
1129            if debug:
1130                print("exchange-created-1",
1131                      f'account: {account}, created: {created}, rate:{rate}, description:{description}')
1132
1133        if account in self._vault['exchange']:
1134            valid_rates = [(ts, r) for ts, r in self._vault['exchange'][account].items() if ts <= created]
1135            if valid_rates:
1136                latest_rate = max(valid_rates, key=lambda x: x[0])
1137                if debug:
1138                    print("exchange-read-1",
1139                          f'account: {account}, created: {created}, rate:{rate}, description:{description}',
1140                          'latest_rate', latest_rate)
1141                result = latest_rate[1]
1142                result['time'] = latest_rate[0]
1143                return result  # إرجاع قاموس يحتوي على المعدل والوصف
1144        if debug:
1145            print("exchange-read-0", f'account: {account}, created: {created}, rate:{rate}, description:{description}')
1146        return {"time": created, "rate": 1, "description": None}  # إرجاع القيمة الافتراضية مع وصف فارغ

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

Parameters:

  • account (str): The account number for which the exchange rate is being recorded or retrieved.
  • created (int): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
  • rate (float): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
  • description (str): A description of the exchange rate.
  • debug (bool): 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.
@staticmethod
def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
1148    @staticmethod
1149    def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
1150        """
1151        This function calculates the exchanged amount of a currency.
1152
1153        Args:
1154            x (float): The original amount of the currency.
1155            x_rate (float): The exchange rate of the original currency.
1156            y_rate (float): The exchange rate of the target currency.
1157
1158        Returns:
1159            float: The exchanged amount of the target currency.
1160        """
1161        return (x * x_rate) / y_rate

This function calculates the exchanged amount of a currency.

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

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

def exchanges(self) -> dict:
1163    def exchanges(self) -> dict:
1164        """
1165        Retrieve the recorded exchange rates for all accounts.
1166
1167        Parameters:
1168        None
1169
1170        Returns:
1171        dict: A dictionary containing all recorded exchange rates.
1172        The keys are account names or numbers, and the values are dictionaries containing the exchange rates.
1173        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
1174        """
1175        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:
1177    def accounts(self) -> dict:
1178        """
1179        Returns a dictionary containing account numbers as keys and their respective balances as values.
1180
1181        Parameters:
1182        None
1183
1184        Returns:
1185        dict: A dictionary where keys are account numbers and values are their respective balances.
1186        """
1187        result = {}
1188        for i in self._vault['account']:
1189            result[i] = self._vault['account'][i]['balance']
1190        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:
1192    def boxes(self, account) -> dict:
1193        """
1194        Retrieve the boxes (transactions) associated with a specific account.
1195
1196        Parameters:
1197        account (str): The account number for which to retrieve the boxes.
1198
1199        Returns:
1200        dict: A dictionary containing the boxes associated with the given account.
1201        If the account does not exist, an empty dictionary is returned.
1202        """
1203        if self.account_exists(account):
1204            return self._vault['account'][account]['box']
1205        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:
1207    def logs(self, account) -> dict:
1208        """
1209        Retrieve the logs (transactions) associated with a specific account.
1210
1211        Parameters:
1212        account (str): The account number for which to retrieve the logs.
1213
1214        Returns:
1215        dict: A dictionary containing the logs associated with the given account.
1216        If the account does not exist, an empty dictionary is returned.
1217        """
1218        if self.account_exists(account):
1219            return self._vault['account'][account]['log']
1220        return {}

Retrieve the logs (transactions) associated with a specific account.

Parameters: account (str): The account number for which to retrieve the logs.

Returns: dict: A dictionary containing the logs associated with the given account. If the account does not exist, an empty dictionary is returned.

def daily_logs(self, debug: bool = False):
1222    def daily_logs(self, debug: bool = False):
1223        """
1224        Retrieve the daily logs (transactions) from all accounts.
1225
1226        The function groups the logs by day, month, and year, and calculates the total value for each group.
1227        It returns a dictionary where the keys are the timestamps of the daily groups,
1228        and the values are dictionaries containing the total value and the logs for that group.
1229
1230        Parameters:
1231        debug (bool): Whether to print debug information. Default is False.
1232
1233        Returns:
1234        dict: A dictionary containing the daily logs.
1235
1236        Example:
1237        >>> tracker = ZakatTracker()
1238        >>> tracker.track(51, 'desc', 'account1')
1239        >>> tracker.track(100, 'desc', 'account2')
1240        >>> tracker.daily_logs()
1241        {
1242            1632057600: {
1243                'total': 151,
1244                'transfer': False,
1245                'rows': [
1246                    {'value': 51, 'account': 'account1', 'file': {}, 'ref': 1690977015000000000, 'desc': 'desc'},
1247                    {'value': 100, 'account': 'account2', 'file': {}, 'ref': 1690977015000000000, 'desc': 'desc'}
1248                ]
1249            }
1250        }
1251        """
1252        logs = {}
1253        for account in self.accounts():
1254            for k, v in self.logs(account).items():
1255                v['time'] = k
1256                v['account'] = account
1257                if k not in logs:
1258                    logs[k] = []
1259                logs[k].append(v)
1260        if debug:
1261            print('logs', logs)
1262        y = {}
1263        for i in sorted(logs, reverse=True):
1264            dt = self.time_to_datetime(i)
1265            group = self.day_to_time(dt.day, dt.month, dt.year)
1266            if group not in y:
1267                y[group] = {
1268                    'total': 0,
1269                    'rows': [],
1270                }
1271            transfer = len(logs[i]) > 1
1272            if debug:
1273                print('logs[i]', logs[i])
1274            for z in logs[i]:
1275                if debug:
1276                    print('z', z)
1277                y[group]['total'] += z['value']
1278                z['transfer'] = transfer
1279                y[group]['rows'].append(z)
1280        if debug:
1281            print('y', y)
1282        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: debug (bool): Whether to print debug information. Default is False.

Returns: dict: A dictionary containing the daily logs.

Example:

>>> tracker = ZakatTracker()
>>> tracker.track(51, 'desc', 'account1')
>>> tracker.track(100, 'desc', 'account2')
>>> tracker.daily_logs()
{
    1632057600: {
        'total': 151,
        'transfer': False,
        'rows': [
            {'value': 51, 'account': 'account1', 'file': {}, 'ref': 1690977015000000000, 'desc': 'desc'},
            {'value': 100, 'account': 'account2', 'file': {}, 'ref': 1690977015000000000, 'desc': 'desc'}
        ]
    }
}
def add_file(self, account: str, ref: int, path: str) -> int:
1284    def add_file(self, account: str, ref: int, path: str) -> int:
1285        """
1286        Adds a file reference to a specific transaction log entry in the vault.
1287
1288        Parameters:
1289        account (str): The account number associated with the transaction log.
1290        ref (int): The reference to the transaction log entry.
1291        path (str): The path of the file to be added.
1292
1293        Returns:
1294        int: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
1295        """
1296        if self.account_exists(account):
1297            if ref in self._vault['account'][account]['log']:
1298                file_ref = self.time()
1299                self._vault['account'][account]['log'][ref]['file'][file_ref] = path
1300                no_lock = self.nolock()
1301                self.lock()
1302                self._step(Action.ADD_FILE, account, ref=ref, file=file_ref)
1303                if no_lock:
1304                    self.free(self.lock())
1305                return file_ref
1306        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:
1308    def remove_file(self, account: str, ref: int, file_ref: int) -> bool:
1309        """
1310        Removes a file reference from a specific transaction log entry in the vault.
1311
1312        Parameters:
1313        account (str): The account number associated with the transaction log.
1314        ref (int): The reference to the transaction log entry.
1315        file_ref (int): The reference of the file to be removed.
1316
1317        Returns:
1318        bool: True if the file reference is successfully removed, False otherwise.
1319        """
1320        if self.account_exists(account):
1321            if ref in self._vault['account'][account]['log']:
1322                if file_ref in self._vault['account'][account]['log'][ref]['file']:
1323                    x = self._vault['account'][account]['log'][ref]['file'][file_ref]
1324                    del self._vault['account'][account]['log'][ref]['file'][file_ref]
1325                    no_lock = self.nolock()
1326                    self.lock()
1327                    self._step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x)
1328                    if no_lock:
1329                        self.free(self.lock())
1330                    return True
1331        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:
1333    def balance(self, account: str = 1, cached: bool = True) -> int:
1334        """
1335        Calculate and return the balance of a specific account.
1336
1337        Parameters:
1338        account (str): The account number. Default is '1'.
1339        cached (bool): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
1340
1341        Returns:
1342        int: The balance of the account.
1343
1344        Note:
1345        If cached is True, the function returns the cached balance.
1346        If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
1347        """
1348        if cached:
1349            return self._vault['account'][account]['balance']
1350        x = 0
1351        return [x := x + y['rest'] for y in self._vault['account'][account]['box'].values()][-1]

Calculate and return the balance of a specific account.

Parameters: account (str): The account number. Default is '1'. cached (bool): If True, use the cached balance. If False, calculate the balance from the box. Default is True.

Returns: int: The balance of the account.

Note: If cached is True, the function returns the cached balance. If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.

def hide(self, account, status: bool = None) -> bool:
1353    def hide(self, account, status: bool = None) -> bool:
1354        """
1355        Check or set the hide status of a specific account.
1356
1357        Parameters:
1358        account (str): The account number.
1359        status (bool, optional): The new hide status. If not provided, the function will return the current status.
1360
1361        Returns:
1362        bool: The current or updated hide status of the account.
1363
1364        Raises:
1365        None
1366
1367        Example:
1368        >>> tracker = ZakatTracker()
1369        >>> ref = tracker.track(51, 'desc', 'account1')
1370        >>> tracker.hide('account1')  # Set the hide status of 'account1' to True
1371        False
1372        >>> tracker.hide('account1', True)  # Set the hide status of 'account1' to True
1373        True
1374        >>> tracker.hide('account1')  # Get the hide status of 'account1' by default
1375        True
1376        >>> tracker.hide('account1', False)
1377        False
1378        """
1379        if self.account_exists(account):
1380            if status is None:
1381                return self._vault['account'][account]['hide']
1382            self._vault['account'][account]['hide'] = status
1383            return status
1384        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:
1386    def zakatable(self, account, status: bool = None) -> bool:
1387        """
1388        Check or set the zakatable status of a specific account.
1389
1390        Parameters:
1391        account (str): The account number.
1392        status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
1393
1394        Returns:
1395        bool: The current or updated zakatable status of the account.
1396
1397        Raises:
1398        None
1399
1400        Example:
1401        >>> tracker = ZakatTracker()
1402        >>> ref = tracker.track(51, 'desc', 'account1')
1403        >>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
1404        True
1405        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
1406        True
1407        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
1408        True
1409        >>> tracker.zakatable('account1', False)
1410        False
1411        """
1412        if self.account_exists(account):
1413            if status is None:
1414                return self._vault['account'][account]['zakatable']
1415            self._vault['account'][account]['zakatable'] = status
1416            return status
1417        return False

Check or set the zakatable status of a specific account.

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

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

Raises: None

Example:

>>> tracker = ZakatTracker()
>>> ref = tracker.track(51, 'desc', 'account1')
>>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
True
>>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
True
>>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
True
>>> tracker.zakatable('account1', False)
False
def sub( self, unscaled_value: float | int | decimal.Decimal, desc: str = '', account: str = 1, created: int = None, debug: bool = False) -> tuple[int, list[tuple[int, int]]] | tuple:
1419    def sub(self, unscaled_value: float | int | Decimal, desc: str = '', account: str = 1, created: int = None,
1420            debug: bool = False) \
1421            -> tuple[
1422                int,
1423                list[
1424                    tuple[int, int],
1425                ],
1426            ] | tuple:
1427        """
1428        Subtracts a specified value from an account's balance.
1429
1430        Parameters:
1431        unscaled_value (float | int | Decimal): The amount to be subtracted.
1432        desc (str): A description for the transaction. Defaults to an empty string.
1433        account (str): The account from which the value will be subtracted. Defaults to '1'.
1434        created (int): The timestamp of the transaction. If not provided, the current timestamp will be used.
1435        debug (bool): A flag indicating whether to print debug information. Defaults to False.
1436
1437        Returns:
1438        tuple: A tuple containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
1439
1440        If the amount to subtract is greater than the account's balance,
1441        the remaining amount will be transferred to a new transaction with a negative value.
1442
1443        Raises:
1444        ValueError: The box transaction happened again in the same nanosecond time.
1445        ValueError: The log transaction happened again in the same nanosecond time.
1446        """
1447        if debug:
1448            print('sub', f'debug={debug}')
1449        if unscaled_value < 0:
1450            return tuple()
1451        if unscaled_value == 0:
1452            ref = self.track(unscaled_value, '', account)
1453            return ref, ref
1454        if created is None:
1455            created = self.time()
1456        no_lock = self.nolock()
1457        self.lock()
1458        self.track(0, '', account)
1459        value = self.scale(unscaled_value)
1460        self._log(value=-value, desc=desc, account=account, created=created, ref=None, debug=debug)
1461        ids = sorted(self._vault['account'][account]['box'].keys())
1462        limit = len(ids) + 1
1463        target = value
1464        if debug:
1465            print('ids', ids)
1466        ages = []
1467        for i in range(-1, -limit, -1):
1468            if target == 0:
1469                break
1470            j = ids[i]
1471            if debug:
1472                print('i', i, 'j', j)
1473            rest = self._vault['account'][account]['box'][j]['rest']
1474            if rest >= target:
1475                self._vault['account'][account]['box'][j]['rest'] -= target
1476                self._step(Action.SUB, account, ref=j, value=target)
1477                ages.append((j, target))
1478                target = 0
1479                break
1480            elif target > rest > 0:
1481                chunk = rest
1482                target -= chunk
1483                self._step(Action.SUB, account, ref=j, value=chunk)
1484                ages.append((j, chunk))
1485                self._vault['account'][account]['box'][j]['rest'] = 0
1486        if target > 0:
1487            self.track(
1488                unscaled_value=self.unscale(-target),
1489                desc=desc,
1490                account=account,
1491                logging=False,
1492                created=created,
1493            )
1494            ages.append((created, target))
1495        if no_lock:
1496            self.free(self.lock())
1497        return created, ages

Subtracts a specified value from an account's balance.

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

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

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

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

def transfer( self, unscaled_amount: float | int | decimal.Decimal, from_account: str, to_account: str, desc: str = '', created: int = None, debug: bool = False) -> list[int]:
1499    def transfer(self, unscaled_amount: float | int | Decimal, from_account: str, to_account: str, desc: str = '',
1500                 created: int = None,
1501                 debug: bool = False) -> list[int]:
1502        """
1503        Transfers a specified value from one account to another.
1504
1505        Parameters:
1506        unscaled_amount (float | int | Decimal): The amount to be transferred.
1507        from_account (str): The account from which the value will be transferred.
1508        to_account (str): The account to which the value will be transferred.
1509        desc (str, optional): A description for the transaction. Defaults to an empty string.
1510        created (int, optional): The timestamp of the transaction. If not provided, the current timestamp will be used.
1511        debug (bool): A flag indicating whether to print debug information. Defaults to False.
1512
1513        Returns:
1514        list[int]: A list of timestamps corresponding to the transactions made during the transfer.
1515
1516        Raises:
1517        ValueError: Transfer to the same account is forbidden.
1518        ValueError: The box transaction happened again in the same nanosecond time.
1519        ValueError: The log transaction happened again in the same nanosecond time.
1520        """
1521        if debug:
1522            print('transfer', f'debug={debug}')
1523        if from_account == to_account:
1524            raise ValueError(f'Transfer to the same account is forbidden. {to_account}')
1525        if unscaled_amount <= 0:
1526            return []
1527        if created is None:
1528            created = self.time()
1529        (_, ages) = self.sub(unscaled_amount, desc, from_account, created, debug=debug)
1530        times = []
1531        source_exchange = self.exchange(from_account, created)
1532        target_exchange = self.exchange(to_account, created)
1533
1534        if debug:
1535            print('ages', ages)
1536
1537        for age, value in ages:
1538            target_amount = int(self.exchange_calc(value, source_exchange['rate'], target_exchange['rate']))
1539            if debug:
1540                print('target_amount', target_amount)
1541            # Perform the transfer
1542            if self.box_exists(to_account, age):
1543                if debug:
1544                    print('box_exists', age)
1545                capital = self._vault['account'][to_account]['box'][age]['capital']
1546                rest = self._vault['account'][to_account]['box'][age]['rest']
1547                if debug:
1548                    print(
1549                        f"Transfer(loop) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1550                selected_age = age
1551                if rest + target_amount > capital:
1552                    self._vault['account'][to_account]['box'][age]['capital'] += target_amount
1553                    selected_age = ZakatTracker.time()
1554                self._vault['account'][to_account]['box'][age]['rest'] += target_amount
1555                self._step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount)
1556                y = self._log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account,
1557                              created=None, ref=None, debug=debug)
1558                times.append((age, y))
1559                continue
1560            if debug:
1561                print(
1562                    f"Transfer(func) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1563            y = self.track(
1564                unscaled_value=self.unscale(int(target_amount)),
1565                desc=desc,
1566                account=to_account,
1567                logging=True,
1568                created=age,
1569                debug=debug,
1570            )
1571            times.append(y)
1572        return times

Transfers a specified value from one account to another.

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

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

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

def check( self, silver_gram_price: float, unscaled_nisab: float | int | decimal.Decimal = None, debug: bool = False, now: int = None, cycle: float = None) -> tuple:
1574    def check(self, silver_gram_price: float, unscaled_nisab: float | int | Decimal = None, debug: bool = False, now: int = None,
1575              cycle: float = None) -> tuple:
1576        """
1577        Check the eligibility for Zakat based on the given parameters.
1578
1579        Parameters:
1580        silver_gram_price (float): The price of a gram of silver.
1581        unscaled_nisab (float | int | Decimal): The minimum amount of wealth required for Zakat. If not provided,
1582                        it will be calculated based on the silver_gram_price.
1583        debug (bool): Flag to enable debug mode.
1584        now (int): The current timestamp. If not provided, it will be calculated using ZakatTracker.time().
1585        cycle (float): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
1586
1587        Returns:
1588        tuple: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics,
1589        and a dictionary containing the Zakat plan.
1590        """
1591        if debug:
1592            print('check', f'debug={debug}')
1593        if now is None:
1594            now = self.time()
1595        if cycle is None:
1596            cycle = ZakatTracker.TimeCycle()
1597        if unscaled_nisab is None:
1598            unscaled_nisab = ZakatTracker.Nisab(silver_gram_price)
1599        nisab = self.scale(unscaled_nisab)
1600        plan = {}
1601        below_nisab = 0
1602        brief = [0, 0, 0]
1603        valid = False
1604        if debug:
1605            print('exchanges', self.exchanges())
1606        for x in self._vault['account']:
1607            if not self.zakatable(x):
1608                continue
1609            _box = self._vault['account'][x]['box']
1610            _log = self._vault['account'][x]['log']
1611            limit = len(_box) + 1
1612            ids = sorted(self._vault['account'][x]['box'].keys())
1613            for i in range(-1, -limit, -1):
1614                j = ids[i]
1615                rest = float(_box[j]['rest'])
1616                if rest <= 0:
1617                    continue
1618                exchange = self.exchange(x, created=self.time())
1619                rest = ZakatTracker.exchange_calc(rest, float(exchange['rate']), 1)
1620                brief[0] += rest
1621                index = limit + i - 1
1622                epoch = (now - j) / cycle
1623                if debug:
1624                    print(f"Epoch: {epoch}", _box[j])
1625                if _box[j]['last'] > 0:
1626                    epoch = (now - _box[j]['last']) / cycle
1627                if debug:
1628                    print(f"Epoch: {epoch}")
1629                epoch = floor(epoch)
1630                if debug:
1631                    print(f"Epoch: {epoch}", type(epoch), epoch == 0, 1 - epoch, epoch)
1632                if epoch == 0:
1633                    continue
1634                if debug:
1635                    print("Epoch - PASSED")
1636                brief[1] += rest
1637                if rest >= nisab:
1638                    total = 0
1639                    for _ in range(epoch):
1640                        total += ZakatTracker.ZakatCut(float(rest) - float(total))
1641                    if total > 0:
1642                        if x not in plan:
1643                            plan[x] = {}
1644                        valid = True
1645                        brief[2] += total
1646                        plan[x][index] = {
1647                            'total': total,
1648                            'count': epoch,
1649                            'box_time': j,
1650                            'box_capital': _box[j]['capital'],
1651                            'box_rest': _box[j]['rest'],
1652                            'box_last': _box[j]['last'],
1653                            'box_total': _box[j]['total'],
1654                            'box_count': _box[j]['count'],
1655                            'box_log': _log[j]['desc'],
1656                            'exchange_rate': exchange['rate'],
1657                            'exchange_time': exchange['time'],
1658                            'exchange_desc': exchange['description'],
1659                        }
1660                else:
1661                    chunk = ZakatTracker.ZakatCut(float(rest))
1662                    if chunk > 0:
1663                        if x not in plan:
1664                            plan[x] = {}
1665                        if j not in plan[x].keys():
1666                            plan[x][index] = {}
1667                        below_nisab += rest
1668                        brief[2] += chunk
1669                        plan[x][index]['below_nisab'] = chunk
1670                        plan[x][index]['total'] = chunk
1671                        plan[x][index]['count'] = epoch
1672                        plan[x][index]['box_time'] = j
1673                        plan[x][index]['box_capital'] = _box[j]['capital']
1674                        plan[x][index]['box_rest'] = _box[j]['rest']
1675                        plan[x][index]['box_last'] = _box[j]['last']
1676                        plan[x][index]['box_total'] = _box[j]['total']
1677                        plan[x][index]['box_count'] = _box[j]['count']
1678                        plan[x][index]['box_log'] = _log[j]['desc']
1679                        plan[x][index]['exchange_rate'] = exchange['rate']
1680                        plan[x][index]['exchange_time'] = exchange['time']
1681                        plan[x][index]['exchange_desc'] = exchange['description']
1682        valid = valid or below_nisab >= nisab
1683        if debug:
1684            print(f"below_nisab({below_nisab}) >= nisab({nisab})")
1685        return valid, brief, plan

Check the eligibility for Zakat based on the given parameters.

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

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

def build_payment_parts(self, scaled_demand: int, positive_only: bool = True) -> dict:
1687    def build_payment_parts(self, scaled_demand: int, positive_only: bool = True) -> dict:
1688        """
1689        Build payment parts for the Zakat distribution.
1690
1691        Parameters:
1692        scaled_demand (int): The total demand for payment in local currency.
1693        positive_only (bool): If True, only consider accounts with positive balance. Default is True.
1694
1695        Returns:
1696        dict: A dictionary containing the payment parts for each account. The dictionary has the following structure:
1697        {
1698            'account': {
1699                'account_id': {'balance': float, 'rate': float, 'part': float},
1700                ...
1701            },
1702            'exceed': bool,
1703            'demand': int,
1704            'total': float,
1705        }
1706        """
1707        total = 0
1708        parts = {
1709            'account': {},
1710            'exceed': False,
1711            'demand': int(round(scaled_demand)),
1712        }
1713        for x, y in self.accounts().items():
1714            if positive_only and y <= 0:
1715                continue
1716            total += float(y)
1717            exchange = self.exchange(x)
1718            parts['account'][x] = {'balance': y, 'rate': exchange['rate'], 'part': 0}
1719        parts['total'] = total
1720        return parts

Build payment parts for the Zakat distribution.

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

Returns: dict: A dictionary containing the payment parts for each account. The dictionary has the following structure: { 'account': { 'account_id': {'balance': float, 'rate': float, 'part': float}, ... }, 'exceed': bool, 'demand': int, 'total': float, }

@staticmethod
def check_payment_parts(parts: dict, debug: bool = False) -> int:
1722    @staticmethod
1723    def check_payment_parts(parts: dict, debug: bool = False) -> int:
1724        """
1725        Checks the validity of payment parts.
1726
1727        Parameters:
1728        parts (dict): A dictionary containing payment parts information.
1729        debug (bool): Flag to enable debug mode.
1730
1731        Returns:
1732        int: Returns 0 if the payment parts are valid, otherwise returns the error code.
1733
1734        Error Codes:
1735        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
1736        2: 'balance', 'rate' or 'part' key is missing in parts['account'][x].
1737        3: 'part' value in parts['account'][x] is less than 0.
1738        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
1739        5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
1740        6: The sum of 'part' values in parts['account'] does not match with 'demand' value.
1741        """
1742        if debug:
1743            print('check_payment_parts', f'debug={debug}')
1744        for i in ['demand', 'account', 'total', 'exceed']:
1745            if i not in parts:
1746                return 1
1747        exceed = parts['exceed']
1748        for x in parts['account']:
1749            for j in ['balance', 'rate', 'part']:
1750                if j not in parts['account'][x]:
1751                    return 2
1752                if parts['account'][x]['part'] < 0:
1753                    return 3
1754                if not exceed and parts['account'][x]['balance'] <= 0:
1755                    return 4
1756        demand = parts['demand']
1757        z = 0
1758        for _, y in parts['account'].items():
1759            if not exceed and y['part'] > y['balance']:
1760                return 5
1761            z += ZakatTracker.exchange_calc(y['part'], y['rate'], 1)
1762        z = round(z, 2)
1763        demand = round(demand, 2)
1764        if debug:
1765            print('check_payment_parts', f'z = {z}, demand = {demand}')
1766            print('check_payment_parts', type(z), type(demand))
1767            print('check_payment_parts', z != demand)
1768            print('check_payment_parts', str(z) != str(demand))
1769        if z != demand and str(z) != str(demand):
1770            return 6
1771        return 0

Checks the validity of payment parts.

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

Returns: int: Returns 0 if the payment parts are valid, otherwise returns the error code.

Error Codes: 1: 'demand', 'account', 'total', or 'exceed' key is missing in parts. 2: 'balance', 'rate' or 'part' key is missing in parts['account'][x]. 3: 'part' value in parts['account'][x] is less than 0. 4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0. 5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value. 6: The sum of 'part' values in parts['account'] does not match with 'demand' value.

def zakat( self, report: tuple, parts: Dict[str, Union[Dict, bool, Any]] = None, debug: bool = False) -> bool:
1773    def zakat(self, report: tuple, parts: Dict[str, Dict | bool | Any] = None, debug: bool = False) -> bool:
1774        """
1775        Perform Zakat calculation based on the given report and optional parts.
1776
1777        Parameters:
1778        report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan.
1779        parts (dict): A dictionary containing the payment parts for the zakat.
1780        debug (bool): A flag indicating whether to print debug information.
1781
1782        Returns:
1783        bool: True if the zakat calculation is successful, False otherwise.
1784        """
1785        if debug:
1786            print('zakat', f'debug={debug}')
1787        valid, _, plan = report
1788        if not valid:
1789            return valid
1790        parts_exist = parts is not None
1791        if parts_exist:
1792            if self.check_payment_parts(parts, debug=debug) != 0:
1793                return False
1794        if debug:
1795            print('######### zakat #######')
1796            print('parts_exist', parts_exist)
1797        no_lock = self.nolock()
1798        self.lock()
1799        report_time = self.time()
1800        self._vault['report'][report_time] = report
1801        self._step(Action.REPORT, ref=report_time)
1802        created = self.time()
1803        for x in plan:
1804            target_exchange = self.exchange(x)
1805            if debug:
1806                print(plan[x])
1807                print('-------------')
1808                print(self._vault['account'][x]['box'])
1809            ids = sorted(self._vault['account'][x]['box'].keys())
1810            if debug:
1811                print('plan[x]', plan[x])
1812            for i in plan[x].keys():
1813                j = ids[i]
1814                if debug:
1815                    print('i', i, 'j', j)
1816                self._step(Action.ZAKAT, account=x, ref=j, value=self._vault['account'][x]['box'][j]['last'],
1817                           key='last',
1818                           math_operation=MathOperation.EQUAL)
1819                self._vault['account'][x]['box'][j]['last'] = created
1820                amount = ZakatTracker.exchange_calc(float(plan[x][i]['total']), 1, float(target_exchange['rate']))
1821                self._vault['account'][x]['box'][j]['total'] += amount
1822                self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='total',
1823                           math_operation=MathOperation.ADDITION)
1824                self._vault['account'][x]['box'][j]['count'] += plan[x][i]['count']
1825                self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['count'], key='count',
1826                           math_operation=MathOperation.ADDITION)
1827                if not parts_exist:
1828                    try:
1829                        self._vault['account'][x]['box'][j]['rest'] -= amount
1830                    except TypeError:
1831                        self._vault['account'][x]['box'][j]['rest'] -= Decimal(amount)
1832                    # self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest',
1833                    #            math_operation=MathOperation.SUBTRACTION)
1834                    self._log(-float(amount), desc='zakat-زكاة', account=x, created=None, ref=j, debug=debug)
1835        if parts_exist:
1836            for account, part in parts['account'].items():
1837                if part['part'] == 0:
1838                    continue
1839                if debug:
1840                    print('zakat-part', account, part['rate'])
1841                target_exchange = self.exchange(account)
1842                amount = ZakatTracker.exchange_calc(part['part'], part['rate'], target_exchange['rate'])
1843                self.sub(
1844                    unscaled_value=self.unscale(amount),
1845                    desc='zakat-part-دفعة-زكاة',
1846                    account=account,
1847                    debug=debug,
1848                )
1849        if no_lock:
1850            self.free(self.lock())
1851        return True

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

Parameters: report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan. parts (dict): A dictionary containing the payment parts for the zakat. debug (bool): A flag indicating whether to print debug information.

Returns: bool: True if the zakat calculation is successful, False otherwise.

def export_json(self, path: str = 'data.json') -> bool:
1853    def export_json(self, path: str = "data.json") -> bool:
1854        """
1855        Exports the current state of the ZakatTracker object to a JSON file.
1856
1857        Parameters:
1858        path (str): The path where the JSON file will be saved. Default is "data.json".
1859
1860        Returns:
1861        bool: True if the export is successful, False otherwise.
1862
1863        Raises:
1864        No specific exceptions are raised by this method.
1865        """
1866        with open(path, "w") as file:
1867            json.dump(self._vault, file, indent=4, cls=JSONEncoder)
1868            return True

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

Parameters: path (str): The path where the JSON file will be saved. Default is "data.json".

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

Raises: No specific exceptions are raised by this method.

def save(self, path: str = None) -> bool:
1870    def save(self, path: str = None) -> bool:
1871        """
1872        Saves the ZakatTracker's current state to a camel file.
1873
1874        This method serializes the internal data (`_vault`).
1875
1876        Parameters:
1877        path (str, optional): File path for saving. Defaults to a predefined location.
1878
1879        Returns:
1880        bool: True if the save operation is successful, False otherwise.
1881        """
1882        if path is None:
1883            path = self.path()
1884        with open(f'{path}.tmp', 'w') as stream:
1885            # first save in tmp file
1886            stream.write(camel.dump(self._vault))
1887            # then move tmp file to original location
1888            shutil.move(f'{path}.tmp', path)
1889            return True

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.

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

def load(self, path: str = None) -> bool:
1891    def load(self, path: str = None) -> bool:
1892        """
1893        Load the current state of the ZakatTracker object from a camel file.
1894
1895        Parameters:
1896        path (str): The path where the camel file is located. If not provided, it will use the default path.
1897
1898        Returns:
1899        bool: True if the load operation is successful, False otherwise.
1900        """
1901        if path is None:
1902            path = self.path()
1903        if os.path.exists(path):
1904            with open(path, 'r') as stream:
1905                self._vault = camel.load(stream.read())
1906                return True
1907        return False

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

Parameters: path (str): The path where the camel file is located. If not provided, it will use the default path.

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

def import_csv_cache_path(self):
1909    def import_csv_cache_path(self):
1910        """
1911        Generates the cache file path for imported CSV data.
1912
1913        This function constructs the file path where cached data from CSV imports
1914        will be stored. The cache file is a camel file (.camel extension) appended
1915        to the base path of the object.
1916
1917        Returns:
1918        str: The full path to the import CSV cache file.
1919
1920        Example:
1921            >>> obj = ZakatTracker('/data/reports')
1922            >>> obj.import_csv_cache_path()
1923            '/data/reports.import_csv.camel'
1924        """
1925        path = str(self.path())
1926        ext = self.ext()
1927        ext_len = len(ext)
1928        if path.endswith(f'.{ext}'):
1929            path = path[:-ext_len-1]
1930        _, filename = os.path.split(path + f'.import_csv.{ext}')
1931        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.

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:
1933    def import_csv(self, path: str = 'file.csv', scale_decimal_places: int = 0, debug: bool = False) -> tuple:
1934        """
1935        The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system.
1936
1937        Parameters:
1938        path (str): The path to the CSV file. Default is 'file.csv'.
1939        scale_decimal_places (int): The number of decimal places to scale the value. Default is 0.
1940        debug (bool): A flag indicating whether to print debug information.
1941
1942        Returns:
1943        tuple: A tuple containing the number of transactions created, the number of transactions found in the cache,
1944                and a dictionary of bad transactions.
1945
1946        Notes:
1947            * Currency Pair Assumption: This function assumes that the exchange rates stored for each account
1948                                        are appropriate for the currency pairs involved in the conversions.
1949            * The exchange rate for each account is based on the last encountered transaction rate that is not equal
1950                to 1.0 or the previous rate for that account.
1951            * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent
1952              transactions of the same account within the whole imported and existing dataset when doing `check` and
1953              `zakat` operations.
1954
1955        Example Usage:
1956            The CSV file should have the following format, rate is optional per transaction:
1957            account, desc, value, date, rate
1958            For example:
1959            safe-45, "Some text", 34872, 1988-06-30 00:00:00, 1
1960        """
1961        if debug:
1962            print('import_csv', f'debug={debug}')
1963        cache: list[int] = []
1964        try:
1965            with open(self.import_csv_cache_path(), 'r') as stream:
1966                cache = camel.load(stream.read())
1967        except:
1968            pass
1969        date_formats = [
1970            "%Y-%m-%d %H:%M:%S",
1971            "%Y-%m-%dT%H:%M:%S",
1972            "%Y-%m-%dT%H%M%S",
1973            "%Y-%m-%d",
1974        ]
1975        created, found, bad = 0, 0, {}
1976        data: dict[int, list] = {}
1977        with open(path, newline='', encoding="utf-8") as f:
1978            i = 0
1979            for row in csv.reader(f, delimiter=','):
1980                i += 1
1981                hashed = hash(tuple(row))
1982                if hashed in cache:
1983                    found += 1
1984                    continue
1985                account = row[0]
1986                desc = row[1]
1987                value = float(row[2])
1988                rate = 1.0
1989                if row[4:5]:  # Empty list if index is out of range
1990                    rate = float(row[4])
1991                date: int = 0
1992                for time_format in date_formats:
1993                    try:
1994                        date = self.time(datetime.datetime.strptime(row[3], time_format))
1995                        break
1996                    except:
1997                        pass
1998                # TODO: not allowed for negative dates in the future after enhance time functions
1999                if date == 0:
2000                    bad[i] = row + ['invalid date']
2001                if value == 0:
2002                    bad[i] = row + ['invalid value']
2003                    continue
2004                if date not in data:
2005                    data[date] = []
2006                data[date].append((i, account, desc, value, date, rate, hashed))
2007
2008        if debug:
2009            print('import_csv', len(data))
2010
2011        if bad:
2012            return created, found, bad
2013
2014        for date, rows in sorted(data.items()):
2015            try:
2016                len_rows = len(rows)
2017                if len_rows == 1:
2018                    (_, account, desc, unscaled_value, date, rate, hashed) = rows[0]
2019                    value = self.unscale(unscaled_value, decimal_places=scale_decimal_places) if scale_decimal_places > 0 else unscaled_value
2020                    if rate > 0:
2021                        self.exchange(account=account, created=date, rate=rate)
2022                    if value > 0:
2023                        self.track(unscaled_value=value, desc=desc, account=account, logging=True, created=date)
2024                    elif value < 0:
2025                        self.sub(unscaled_value=-value, desc=desc, account=account, created=date)
2026                    created += 1
2027                    cache.append(hashed)
2028                    continue
2029                if debug:
2030                    print('-- Duplicated time detected', date, 'len', len_rows)
2031                    print(rows)
2032                    print('---------------------------------')
2033                # If records are found at the same time with different accounts in the same amount
2034                # (one positive and the other negative), this indicates it is a transfer.
2035                if len_rows != 2:
2036                    raise Exception(f'more than two transactions({len_rows}) at the same time')
2037                (i, account1, desc1, unscaled_value1, date1, rate1, _) = rows[0]
2038                (j, account2, desc2, unscaled_value2, date2, rate2, _) = rows[1]
2039                if account1 == account2 or desc1 != desc2 or abs(unscaled_value1) != abs(unscaled_value2) or date1 != date2:
2040                    raise Exception('invalid transfer')
2041                if rate1 > 0:
2042                    self.exchange(account1, created=date1, rate=rate1)
2043                if rate2 > 0:
2044                    self.exchange(account2, created=date2, rate=rate2)
2045                value1 = self.unscale(unscaled_value1, decimal_places=scale_decimal_places) if scale_decimal_places > 0 else unscaled_value1
2046                value2 = self.unscale(unscaled_value2, decimal_places=scale_decimal_places) if scale_decimal_places > 0 else unscaled_value2
2047                values = {
2048                    value1: account1,
2049                    value2: account2,
2050                }
2051                self.transfer(
2052                    unscaled_amount=abs(value1),
2053                    from_account=values[min(values.keys())],
2054                    to_account=values[max(values.keys())],
2055                    desc=desc1,
2056                    created=date1,
2057                )
2058            except Exception as e:
2059                for (i, account, desc, value, date, rate, _) in rows:
2060                    bad[i] = (account, desc, value, date, rate, e)
2061                break
2062        with open(self.import_csv_cache_path(), 'w') as stream:
2063            stream.write(camel.dump(cache))
2064        return created, found, bad

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

Parameters: path (str): The path to the CSV file. Default is 'file.csv'. scale_decimal_places (int): The number of decimal places to scale the value. Default is 0. debug (bool): A flag indicating whether to print debug information.

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

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

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

@staticmethod
def human_readable_size(size: float, decimal_places: int = 2) -> str:
2070    @staticmethod
2071    def human_readable_size(size: float, decimal_places: int = 2) -> str:
2072        """
2073        Converts a size in bytes to a human-readable format (e.g., KB, MB, GB).
2074
2075        This function iterates through progressively larger units of information
2076        (B, KB, MB, GB, etc.) and divides the input size until it fits within a
2077        range that can be expressed with a reasonable number before the unit.
2078
2079        Parameters:
2080        size (float): The size in bytes to convert.
2081        decimal_places (int, optional): The number of decimal places to display
2082            in the result. Defaults to 2.
2083
2084        Returns:
2085        str: A string representation of the size in a human-readable format,
2086            rounded to the specified number of decimal places. For example:
2087                - "1.50 KB" (1536 bytes)
2088                - "23.00 MB" (24117248 bytes)
2089                - "1.23 GB" (1325899906 bytes)
2090        """
2091        if type(size) not in (float, int):
2092            raise TypeError("size must be a float or integer")
2093        if type(decimal_places) != int:
2094            raise TypeError("decimal_places must be an integer")
2095        for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']:
2096            if size < 1024.0:
2097                break
2098            size /= 1024.0
2099        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:
2101    @staticmethod
2102    def get_dict_size(obj: dict, seen: set = None) -> float:
2103        """
2104        Recursively calculates the approximate memory size of a dictionary and its contents in bytes.
2105
2106        This function traverses the dictionary structure, accounting for the size of keys, values,
2107        and any nested objects. It handles various data types commonly found in dictionaries
2108        (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case
2109        of circular references.
2110
2111        Parameters:
2112        obj (dict): The dictionary whose size is to be calculated.
2113        seen (set, optional): A set used internally to track visited objects
2114                             and avoid circular references. Defaults to None.
2115
2116        Returns:
2117            float: An approximate size of the dictionary and its contents in bytes.
2118
2119        Note:
2120        - This function is a method of the `ZakatTracker` class and is likely used to
2121          estimate the memory footprint of data structures relevant to Zakat calculations.
2122        - The size calculation is approximate as it relies on `sys.getsizeof()`, which might
2123          not account for all memory overhead depending on the Python implementation.
2124        - Circular references are handled to prevent infinite recursion.
2125        - Basic numeric types (int, float, complex) are assumed to have fixed sizes.
2126        - String sizes are estimated based on character length and encoding.
2127        """
2128        size = 0
2129        if seen is None:
2130            seen = set()
2131
2132        obj_id = id(obj)
2133        if obj_id in seen:
2134            return 0
2135
2136        seen.add(obj_id)
2137        size += sys.getsizeof(obj)
2138
2139        if isinstance(obj, dict):
2140            for k, v in obj.items():
2141                size += ZakatTracker.get_dict_size(k, seen)
2142                size += ZakatTracker.get_dict_size(v, seen)
2143        elif isinstance(obj, (list, tuple, set, frozenset)):
2144            for item in obj:
2145                size += ZakatTracker.get_dict_size(item, seen)
2146        elif isinstance(obj, (int, float, complex)):  # Handle numbers
2147            pass  # Basic numbers have a fixed size, so nothing to add here
2148        elif isinstance(obj, str):  # Handle strings
2149            size += len(obj) * sys.getsizeof(str().encode())  # Size per character in bytes
2150        return size

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

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

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

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

Note:

  • This function is a method of the ZakatTracker class and is likely used to estimate the memory footprint of data structures relevant to Zakat calculations.
  • The size calculation is approximate as it relies on sys.getsizeof(), which might not account for all memory overhead depending on the Python implementation.
  • Circular references are handled to prevent infinite recursion.
  • Basic numeric types (int, float, complex) are assumed to have fixed sizes.
  • String sizes are estimated based on character length and encoding.
@staticmethod
def duration_from_nanoseconds( ns: int, show_zeros_in_spoken_time: bool = False, spoken_time_separator=',', millennia: str = 'Millennia', century: str = 'Century', years: str = 'Years', days: str = 'Days', hours: str = 'Hours', minutes: str = 'Minutes', seconds: str = 'Seconds', milli_seconds: str = 'MilliSeconds', micro_seconds: str = 'MicroSeconds', nano_seconds: str = 'NanoSeconds') -> tuple:
2152    @staticmethod
2153    def duration_from_nanoseconds(ns: int,
2154                                  show_zeros_in_spoken_time: bool = False,
2155                                  spoken_time_separator=',',
2156                                  millennia: str = 'Millennia',
2157                                  century: str = 'Century',
2158                                  years: str = 'Years',
2159                                  days: str = 'Days',
2160                                  hours: str = 'Hours',
2161                                  minutes: str = 'Minutes',
2162                                  seconds: str = 'Seconds',
2163                                  milli_seconds: str = 'MilliSeconds',
2164                                  micro_seconds: str = 'MicroSeconds',
2165                                  nano_seconds: str = 'NanoSeconds',
2166                                  ) -> tuple:
2167        """
2168        REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
2169        Convert NanoSeconds to Human Readable Time Format.
2170        A NanoSeconds is a unit of time in the International System of Units (SI) equal
2171        to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
2172        Its symbol is μs, sometimes simplified to us when Unicode is not available.
2173        A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
2174
2175        INPUT : ms (AKA: MilliSeconds)
2176        OUTPUT: tuple(string time_lapsed, string spoken_time) like format.
2177        OUTPUT Variables: time_lapsed, spoken_time
2178
2179        Example  Input: duration_from_nanoseconds(ns)
2180        **"Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds"**
2181        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')
2182        duration_from_nanoseconds(1234567890123456789012)
2183        """
2184        us, ns = divmod(ns, 1000)
2185        ms, us = divmod(us, 1000)
2186        s, ms = divmod(ms, 1000)
2187        m, s = divmod(s, 60)
2188        h, m = divmod(m, 60)
2189        d, h = divmod(h, 24)
2190        y, d = divmod(d, 365)
2191        c, y = divmod(y, 100)
2192        n, c = divmod(c, 10)
2193        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}"
2194        spoken_time_part = []
2195        if n > 0 or show_zeros_in_spoken_time:
2196            spoken_time_part.append(f"{n: 3d} {millennia}")
2197        if c > 0 or show_zeros_in_spoken_time:
2198            spoken_time_part.append(f"{c: 4d} {century}")
2199        if y > 0 or show_zeros_in_spoken_time:
2200            spoken_time_part.append(f"{y: 3d} {years}")
2201        if d > 0 or show_zeros_in_spoken_time:
2202            spoken_time_part.append(f"{d: 4d} {days}")
2203        if h > 0 or show_zeros_in_spoken_time:
2204            spoken_time_part.append(f"{h: 2d} {hours}")
2205        if m > 0 or show_zeros_in_spoken_time:
2206            spoken_time_part.append(f"{m: 2d} {minutes}")
2207        if s > 0 or show_zeros_in_spoken_time:
2208            spoken_time_part.append(f"{s: 2d} {seconds}")
2209        if ms > 0 or show_zeros_in_spoken_time:
2210            spoken_time_part.append(f"{ms: 3d} {milli_seconds}")
2211        if us > 0 or show_zeros_in_spoken_time:
2212            spoken_time_part.append(f"{us: 3d} {micro_seconds}")
2213        if ns > 0 or show_zeros_in_spoken_time:
2214            spoken_time_part.append(f"{ns: 3d} {nano_seconds}")
2215        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:
2217    @staticmethod
2218    def day_to_time(day: int, month: int = 6, year: int = 2024) -> int:  # افتراض أن الشهر هو يونيو والسنة 2024
2219        """
2220        Convert a specific day, month, and year into a timestamp.
2221
2222        Parameters:
2223        day (int): The day of the month.
2224        month (int): The month of the year. Default is 6 (June).
2225        year (int): The year. Default is 2024.
2226
2227        Returns:
2228        int: The timestamp representing the given day, month, and year.
2229
2230        Note:
2231        This method assumes the default month and year if not provided.
2232        """
2233        return ZakatTracker.time(datetime.datetime(year, month, day))

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

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

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

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

@staticmethod
def generate_random_date( start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
2235    @staticmethod
2236    def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
2237        """
2238        Generate a random date between two given dates.
2239
2240        Parameters:
2241        start_date (datetime.datetime): The start date from which to generate a random date.
2242        end_date (datetime.datetime): The end date until which to generate a random date.
2243
2244        Returns:
2245        datetime.datetime: A random date between the start_date and end_date.
2246        """
2247        time_between_dates = end_date - start_date
2248        days_between_dates = time_between_dates.days
2249        random_number_of_days = random.randrange(days_between_dates)
2250        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:
2252    @staticmethod
2253    def generate_random_csv_file(path: str = "data.csv", count: int = 1000, with_rate: bool = False,
2254                                 debug: bool = False) -> int:
2255        """
2256        Generate a random CSV file with specified parameters.
2257
2258        Parameters:
2259        path (str): The path where the CSV file will be saved. Default is "data.csv".
2260        count (int): The number of rows to generate in the CSV file. Default is 1000.
2261        with_rate (bool): If True, a random rate between 1.2% and 12% is added. Default is False.
2262        debug (bool): A flag indicating whether to print debug information.
2263
2264        Returns:
2265        None. The function generates a CSV file at the specified path with the given count of rows.
2266        Each row contains a randomly generated account, description, value, and date.
2267        The value is randomly generated between 1000 and 100000,
2268        and the date is randomly generated between 1950-01-01 and 2023-12-31.
2269        If the row number is not divisible by 13, the value is multiplied by -1.
2270        """
2271        if debug:
2272            print('generate_random_csv_file', f'debug={debug}')
2273        i = 0
2274        with open(path, "w", newline="") as csvfile:
2275            writer = csv.writer(csvfile)
2276            for i in range(count):
2277                account = f"acc-{random.randint(1, 1000)}"
2278                desc = f"Some text {random.randint(1, 1000)}"
2279                value = random.randint(1000, 100000)
2280                date = ZakatTracker.generate_random_date(datetime.datetime(1000, 1, 1),
2281                                                         datetime.datetime(2023, 12, 31)).strftime("%Y-%m-%d %H:%M:%S")
2282                if not i % 13 == 0:
2283                    value *= -1
2284                row = [account, desc, value, date]
2285                if with_rate:
2286                    rate = random.randint(1, 100) * 0.12
2287                    if debug:
2288                        print('before-append', row)
2289                    row.append(rate)
2290                    if debug:
2291                        print('after-append', row)
2292                writer.writerow(row)
2293                i = i + 1
2294        return i

Generate a random CSV file with specified parameters.

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

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

@staticmethod
def create_random_list(max_sum, min_value=0, max_value=10):
2296    @staticmethod
2297    def create_random_list(max_sum, min_value=0, max_value=10):
2298        """
2299        Creates a list of random integers whose sum does not exceed the specified maximum.
2300
2301        Args:
2302            max_sum: The maximum allowed sum of the list elements.
2303            min_value: The minimum possible value for an element (inclusive).
2304            max_value: The maximum possible value for an element (inclusive).
2305
2306        Returns:
2307            A list of random integers.
2308        """
2309        result = []
2310        current_sum = 0
2311
2312        while current_sum < max_sum:
2313            # Calculate the remaining space for the next element
2314            remaining_sum = max_sum - current_sum
2315            # Determine the maximum possible value for the next element
2316            next_max_value = min(remaining_sum, max_value)
2317            # Generate a random element within the allowed range
2318            next_element = random.randint(min_value, next_max_value)
2319            result.append(next_element)
2320            current_sum += next_element
2321
2322        return result

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

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

Returns: A list of random integers.

def test(self, debug: bool = False) -> bool:
2553    def test(self, debug: bool = False) -> bool:
2554        if debug:
2555            print('test', f'debug={debug}')
2556        try:
2557
2558            self._test_core(True, debug)
2559            self._test_core(False, debug)
2560
2561            assert self._history()
2562
2563            # Not allowed for duplicate transactions in the same account and time
2564
2565            created = ZakatTracker.time()
2566            self.track(100, 'test-1', 'same', True, created)
2567            failed = False
2568            try:
2569                self.track(50, 'test-1', 'same', True, created)
2570            except:
2571                failed = True
2572            assert failed is True
2573
2574            self.reset()
2575
2576            # Same account transfer
2577            for x in [1, 'a', True, 1.8, None]:
2578                failed = False
2579                try:
2580                    self.transfer(1, x, x, 'same-account', debug=debug)
2581                except:
2582                    failed = True
2583                assert failed is True
2584
2585            # Always preserve box age during transfer
2586
2587            series: list[tuple] = [
2588                (30, 4),
2589                (60, 3),
2590                (90, 2),
2591            ]
2592            case = {
2593                3000: {
2594                    'series': series,
2595                    'rest': 15000,
2596                },
2597                6000: {
2598                    'series': series,
2599                    'rest': 12000,
2600                },
2601                9000: {
2602                    'series': series,
2603                    'rest': 9000,
2604                },
2605                18000: {
2606                    'series': series,
2607                    'rest': 0,
2608                },
2609                27000: {
2610                    'series': series,
2611                    'rest': -9000,
2612                },
2613                36000: {
2614                    'series': series,
2615                    'rest': -18000,
2616                },
2617            }
2618
2619            selected_time = ZakatTracker.time() - ZakatTracker.TimeCycle()
2620
2621            for total in case:
2622                if debug:
2623                    print('--------------------------------------------------------')
2624                    print(f'case[{total}]', case[total])
2625                for x in case[total]['series']:
2626                    self.track(
2627                        unscaled_value=x[0],
2628                        desc=f"test-{x} ages",
2629                        account='ages',
2630                        logging=True,
2631                        created=selected_time * x[1],
2632                    )
2633
2634                unscaled_total = self.unscale(total)
2635                if debug:
2636                    print('unscaled_total', unscaled_total)
2637                refs = self.transfer(
2638                    unscaled_amount=unscaled_total,
2639                    from_account='ages',
2640                    to_account='future',
2641                    desc='Zakat Movement',
2642                    debug=debug,
2643                )
2644
2645                if debug:
2646                    print('refs', refs)
2647
2648                ages_cache_balance = self.balance('ages')
2649                ages_fresh_balance = self.balance('ages', False)
2650                rest = case[total]['rest']
2651                if debug:
2652                    print('source', ages_cache_balance, ages_fresh_balance, rest)
2653                assert ages_cache_balance == rest
2654                assert ages_fresh_balance == rest
2655
2656                future_cache_balance = self.balance('future')
2657                future_fresh_balance = self.balance('future', False)
2658                if debug:
2659                    print('target', future_cache_balance, future_fresh_balance, total)
2660                    print('refs', refs)
2661                assert future_cache_balance == total
2662                assert future_fresh_balance == total
2663
2664                # TODO: check boxes times for `ages` should equal box times in `future`
2665                for ref in self._vault['account']['ages']['box']:
2666                    ages_capital = self._vault['account']['ages']['box'][ref]['capital']
2667                    ages_rest = self._vault['account']['ages']['box'][ref]['rest']
2668                    future_capital = 0
2669                    if ref in self._vault['account']['future']['box']:
2670                        future_capital = self._vault['account']['future']['box'][ref]['capital']
2671                    future_rest = 0
2672                    if ref in self._vault['account']['future']['box']:
2673                        future_rest = self._vault['account']['future']['box'][ref]['rest']
2674                    if ages_capital != 0 and future_capital != 0 and future_rest != 0:
2675                        if debug:
2676                            print('================================================================')
2677                            print('ages', ages_capital, ages_rest)
2678                            print('future', future_capital, future_rest)
2679                        if ages_rest == 0:
2680                            assert ages_capital == future_capital
2681                        elif ages_rest < 0:
2682                            assert -ages_capital == future_capital
2683                        elif ages_rest > 0:
2684                            assert ages_capital == ages_rest + future_capital
2685                self.reset()
2686                assert len(self._vault['history']) == 0
2687
2688            assert self._history()
2689            assert self._history(False) is False
2690            assert self._history() is False
2691            assert self._history(True)
2692            assert self._history()
2693            if debug:
2694                print('####################################################################')
2695
2696            transaction = [
2697                (
2698                    20, 'wallet', 1, -2000, -2000, -2000, 1, 1,
2699                    2000, 2000, 2000, 1, 1,
2700                ),
2701                (
2702                    750, 'wallet', 'safe', -77000, -77000, -77000, 2, 2,
2703                    75000, 75000, 75000, 1, 1,
2704                ),
2705                (
2706                    600, 'safe', 'bank', 15000, 15000, 15000, 1, 2,
2707                    60000, 60000, 60000, 1, 1,
2708                ),
2709            ]
2710            for z in transaction:
2711                self.lock()
2712                x = z[1]
2713                y = z[2]
2714                self.transfer(
2715                    unscaled_amount=z[0],
2716                    from_account=x,
2717                    to_account=y,
2718                    desc='test-transfer',
2719                    debug=debug,
2720                )
2721                zz = self.balance(x)
2722                if debug:
2723                    print(zz, z)
2724                assert zz == z[3]
2725                xx = self.accounts()[x]
2726                assert xx == z[3]
2727                assert self.balance(x, False) == z[4]
2728                assert xx == z[4]
2729
2730                s = 0
2731                log = self._vault['account'][x]['log']
2732                for i in log:
2733                    s += log[i]['value']
2734                if debug:
2735                    print('s', s, 'z[5]', z[5])
2736                assert s == z[5]
2737
2738                assert self.box_size(x) == z[6]
2739                assert self.log_size(x) == z[7]
2740
2741                yy = self.accounts()[y]
2742                assert self.balance(y) == z[8]
2743                assert yy == z[8]
2744                assert self.balance(y, False) == z[9]
2745                assert yy == z[9]
2746
2747                s = 0
2748                log = self._vault['account'][y]['log']
2749                for i in log:
2750                    s += log[i]['value']
2751                assert s == z[10]
2752
2753                assert self.box_size(y) == z[11]
2754                assert self.log_size(y) == z[12]
2755                assert self.free(self.lock())
2756
2757            if debug:
2758                pp().pprint(self.check(2.17))
2759
2760            assert not self.nolock()
2761            history_count = len(self._vault['history'])
2762            if debug:
2763                print('history-count', history_count)
2764            assert history_count == 4
2765            assert not self.free(ZakatTracker.time())
2766            assert self.free(self.lock())
2767            assert self.nolock()
2768            assert len(self._vault['history']) == 3
2769
2770            # storage
2771
2772            _path = self.path(f'./zakat_test_db/test.{self.ext()}')
2773            if os.path.exists(_path):
2774                os.remove(_path)
2775            self.save()
2776            assert os.path.getsize(_path) > 0
2777            self.reset()
2778            assert self.recall(False, debug) is False
2779            self.load()
2780            assert self._vault['account'] is not None
2781
2782            # recall
2783
2784            assert self.nolock()
2785            assert len(self._vault['history']) == 3
2786            assert self.recall(False, debug) is True
2787            assert len(self._vault['history']) == 2
2788            assert self.recall(False, debug) is True
2789            assert len(self._vault['history']) == 1
2790            assert self.recall(False, debug) is True
2791            assert len(self._vault['history']) == 0
2792            assert self.recall(False, debug) is False
2793            assert len(self._vault['history']) == 0
2794
2795            # exchange
2796
2797            self.exchange("cash", 25, 3.75, "2024-06-25")
2798            self.exchange("cash", 22, 3.73, "2024-06-22")
2799            self.exchange("cash", 15, 3.69, "2024-06-15")
2800            self.exchange("cash", 10, 3.66)
2801
2802            for i in range(1, 30):
2803                exchange = self.exchange("cash", i)
2804                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2805                if debug:
2806                    print(i, rate, description, created)
2807                assert created
2808                if i < 10:
2809                    assert rate == 1
2810                    assert description is None
2811                elif i == 10:
2812                    assert rate == 3.66
2813                    assert description is None
2814                elif i < 15:
2815                    assert rate == 3.66
2816                    assert description is None
2817                elif i == 15:
2818                    assert rate == 3.69
2819                    assert description is not None
2820                elif i < 22:
2821                    assert rate == 3.69
2822                    assert description is not None
2823                elif i == 22:
2824                    assert rate == 3.73
2825                    assert description is not None
2826                elif i >= 25:
2827                    assert rate == 3.75
2828                    assert description is not None
2829                exchange = self.exchange("bank", i)
2830                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2831                if debug:
2832                    print(i, rate, description, created)
2833                assert created
2834                assert rate == 1
2835                assert description is None
2836
2837            assert len(self._vault['exchange']) > 0
2838            assert len(self.exchanges()) > 0
2839            self._vault['exchange'].clear()
2840            assert len(self._vault['exchange']) == 0
2841            assert len(self.exchanges()) == 0
2842
2843            # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية
2844            self.exchange("cash", ZakatTracker.day_to_time(25), 3.75, "2024-06-25")
2845            self.exchange("cash", ZakatTracker.day_to_time(22), 3.73, "2024-06-22")
2846            self.exchange("cash", ZakatTracker.day_to_time(15), 3.69, "2024-06-15")
2847            self.exchange("cash", ZakatTracker.day_to_time(10), 3.66)
2848
2849            for i in [x * 0.12 for x in range(-15, 21)]:
2850                if i <= 0:
2851                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) == 0
2852                else:
2853                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) > 0
2854
2855            # اختبار النتائج باستخدام التواريخ بالنانو ثانية
2856            for i in range(1, 31):
2857                timestamp_ns = ZakatTracker.day_to_time(i)
2858                exchange = self.exchange("cash", timestamp_ns)
2859                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2860                if debug:
2861                    print(i, rate, description, created)
2862                assert created
2863                if i < 10:
2864                    assert rate == 1
2865                    assert description is None
2866                elif i == 10:
2867                    assert rate == 3.66
2868                    assert description is None
2869                elif i < 15:
2870                    assert rate == 3.66
2871                    assert description is None
2872                elif i == 15:
2873                    assert rate == 3.69
2874                    assert description is not None
2875                elif i < 22:
2876                    assert rate == 3.69
2877                    assert description is not None
2878                elif i == 22:
2879                    assert rate == 3.73
2880                    assert description is not None
2881                elif i >= 25:
2882                    assert rate == 3.75
2883                    assert description is not None
2884                exchange = self.exchange("bank", i)
2885                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2886                if debug:
2887                    print(i, rate, description, created)
2888                assert created
2889                assert rate == 1
2890                assert description is None
2891
2892            # csv
2893
2894            csv_count = 1000
2895
2896            for with_rate, path in {
2897                False: 'test-import_csv-no-exchange',
2898                True: 'test-import_csv-with-exchange',
2899            }.items():
2900
2901                if debug:
2902                    print('test_import_csv', with_rate, path)
2903
2904                csv_path = path + '.csv'
2905                if os.path.exists(csv_path):
2906                    os.remove(csv_path)
2907                c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug)
2908                if debug:
2909                    print('generate_random_csv_file', c)
2910                assert c == csv_count
2911                assert os.path.getsize(csv_path) > 0
2912                cache_path = self.import_csv_cache_path()
2913                if os.path.exists(cache_path):
2914                    os.remove(cache_path)
2915                self.reset()
2916                (created, found, bad) = self.import_csv(csv_path, debug)
2917                bad_count = len(bad)
2918                assert bad_count > 0
2919                if debug:
2920                    print(f"csv-imported: ({created}, {found}, {bad_count}) = count({csv_count})")
2921                    print('bad', bad)
2922                tmp_size = os.path.getsize(cache_path)
2923                assert tmp_size > 0
2924                # TODO: assert created + found + bad_count == csv_count
2925                # TODO: assert created == csv_count
2926                # TODO: assert bad_count == 0
2927                (created_2, found_2, bad_2) = self.import_csv(csv_path)
2928                bad_2_count = len(bad_2)
2929                if debug:
2930                    print(f"csv-imported: ({created_2}, {found_2}, {bad_2_count})")
2931                    print('bad', bad)
2932                assert bad_2_count > 0
2933                # TODO: assert tmp_size == os.path.getsize(cache_path)
2934                # TODO: assert created_2 + found_2 + bad_2_count == csv_count
2935                # TODO: assert created == found_2
2936                # TODO: assert bad_count == bad_2_count
2937                # TODO: assert found_2 == csv_count
2938                # TODO: assert bad_2_count == 0
2939                # TODO: assert created_2 == 0
2940
2941                # payment parts
2942
2943                positive_parts = self.build_payment_parts(100, positive_only=True)
2944                assert self.check_payment_parts(positive_parts) != 0
2945                assert self.check_payment_parts(positive_parts) != 0
2946                all_parts = self.build_payment_parts(300, positive_only=False)
2947                assert self.check_payment_parts(all_parts) != 0
2948                assert self.check_payment_parts(all_parts) != 0
2949                if debug:
2950                    pp().pprint(positive_parts)
2951                    pp().pprint(all_parts)
2952                # dynamic discount
2953                suite = []
2954                count = 3
2955                for exceed in [False, True]:
2956                    case = []
2957                    for parts in [positive_parts, all_parts]:
2958                        part = parts.copy()
2959                        demand = part['demand']
2960                        if debug:
2961                            print(demand, part['total'])
2962                        i = 0
2963                        z = demand / count
2964                        cp = {
2965                            'account': {},
2966                            'demand': demand,
2967                            'exceed': exceed,
2968                            'total': part['total'],
2969                        }
2970                        j = ''
2971                        for x, y in part['account'].items():
2972                            x_exchange = self.exchange(x)
2973                            zz = self.exchange_calc(z, 1, x_exchange['rate'])
2974                            if exceed and zz <= demand:
2975                                i += 1
2976                                y['part'] = zz
2977                                if debug:
2978                                    print(exceed, y)
2979                                cp['account'][x] = y
2980                                case.append(y)
2981                            elif not exceed and y['balance'] >= zz:
2982                                i += 1
2983                                y['part'] = zz
2984                                if debug:
2985                                    print(exceed, y)
2986                                cp['account'][x] = y
2987                                case.append(y)
2988                            j = x
2989                            if i >= count:
2990                                break
2991                        if len(cp['account'][j]) > 0:
2992                            suite.append(cp)
2993                if debug:
2994                    print('suite', len(suite))
2995                # vault = self._vault.copy()
2996                for case in suite:
2997                    # self._vault = vault.copy()
2998                    if debug:
2999                        print('case', case)
3000                    result = self.check_payment_parts(case)
3001                    if debug:
3002                        print('check_payment_parts', result, f'exceed: {exceed}')
3003                    assert result == 0
3004
3005                    report = self.check(2.17, None, debug)
3006                    (valid, brief, plan) = report
3007                    if debug:
3008                        print('valid', valid)
3009                    zakat_result = self.zakat(report, parts=case, debug=debug)
3010                    if debug:
3011                        print('zakat-result', zakat_result)
3012                    assert valid == zakat_result
3013
3014            assert self.save(path + f'.{self.ext()}')
3015            assert self.export_json(path + '.json')
3016
3017            assert self.export_json("1000-transactions-test.json")
3018            assert self.save(f"1000-transactions-test.{self.ext()}")
3019
3020            self.reset()
3021
3022            # test transfer between accounts with different exchange rate
3023
3024            a_SAR = "Bank (SAR)"
3025            b_USD = "Bank (USD)"
3026            c_SAR = "Safe (SAR)"
3027            # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer
3028            for case in [
3029                (0, a_SAR, "SAR Gift", 1000, 100000),
3030                (1, a_SAR, 1),
3031                (0, b_USD, "USD Gift", 500, 50000),
3032                (1, b_USD, 1),
3033                (2, b_USD, 3.75),
3034                (1, b_USD, 3.75),
3035                (3, 100, b_USD, a_SAR, "100 USD -> SAR", 40000, 137500),
3036                (0, c_SAR, "Salary", 750, 75000),
3037                (3, 375, c_SAR, b_USD, "375 SAR -> USD", 37500, 50000),
3038                (3, 3.75, a_SAR, b_USD, "3.75 SAR -> USD", 137125, 50100),
3039            ]:
3040                if debug:
3041                    print('case', case)
3042                match (case[0]):
3043                    case 0:  # track
3044                        _, account, desc, x, balance = case
3045                        self.track(unscaled_value=x, desc=desc, account=account, debug=debug)
3046
3047                        cached_value = self.balance(account, cached=True)
3048                        fresh_value = self.balance(account, cached=False)
3049                        if debug:
3050                            print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value)
3051                        assert cached_value == balance
3052                        assert fresh_value == balance
3053                    case 1:  # check-exchange
3054                        _, account, expected_rate = case
3055                        t_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
3056                        if debug:
3057                            print('t-exchange', t_exchange)
3058                        assert t_exchange['rate'] == expected_rate
3059                    case 2:  # do-exchange
3060                        _, account, rate = case
3061                        self.exchange(account, rate=rate, debug=debug)
3062                        b_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
3063                        if debug:
3064                            print('b-exchange', b_exchange)
3065                        assert b_exchange['rate'] == rate
3066                    case 3:  # transfer
3067                        _, x, a, b, desc, a_balance, b_balance = case
3068                        self.transfer(x, a, b, desc, debug=debug)
3069
3070                        cached_value = self.balance(a, cached=True)
3071                        fresh_value = self.balance(a, cached=False)
3072                        if debug:
3073                            print('account', a, 'cached_value', cached_value, 'fresh_value', fresh_value, 'a_balance', a_balance)
3074                        assert cached_value == a_balance
3075                        assert fresh_value == a_balance
3076
3077                        cached_value = self.balance(b, cached=True)
3078                        fresh_value = self.balance(b, cached=False)
3079                        if debug:
3080                            print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value)
3081                        assert cached_value == b_balance
3082                        assert fresh_value == b_balance
3083
3084            # Transfer all in many chunks randomly from B to A
3085            a_SAR_balance = 137125
3086            b_USD_balance = 50100
3087            b_USD_exchange = self.exchange(b_USD)
3088            amounts = ZakatTracker.create_random_list(b_USD_balance, max_value=1000)
3089            if debug:
3090                print('amounts', amounts)
3091            i = 0
3092            for x in amounts:
3093                if debug:
3094                    print(f'{i} - transfer-with-exchange({x})')
3095                self.transfer(
3096                    unscaled_amount=self.unscale(x),
3097                    from_account=b_USD,
3098                    to_account=a_SAR,
3099                    desc=f"{x} USD -> SAR",
3100                    debug=debug,
3101                )
3102
3103                b_USD_balance -= x
3104                cached_value = self.balance(b_USD, cached=True)
3105                fresh_value = self.balance(b_USD, cached=False)
3106                if debug:
3107                    print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
3108                          b_USD_balance)
3109                assert cached_value == b_USD_balance
3110                assert fresh_value == b_USD_balance
3111
3112                a_SAR_balance += int(x * b_USD_exchange['rate'])
3113                cached_value = self.balance(a_SAR, cached=True)
3114                fresh_value = self.balance(a_SAR, cached=False)
3115                if debug:
3116                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
3117                          a_SAR_balance, 'rate', b_USD_exchange['rate'])
3118                assert cached_value == a_SAR_balance
3119                assert fresh_value == a_SAR_balance
3120                i += 1
3121
3122            # Transfer all in many chunks randomly from C to A
3123            c_SAR_balance = 37500
3124            amounts = ZakatTracker.create_random_list(c_SAR_balance, max_value=1000)
3125            if debug:
3126                print('amounts', amounts)
3127            i = 0
3128            for x in amounts:
3129                if debug:
3130                    print(f'{i} - transfer-with-exchange({x})')
3131                self.transfer(
3132                    unscaled_amount=self.unscale(x),
3133                    from_account=c_SAR,
3134                    to_account=a_SAR,
3135                    desc=f"{x} SAR -> a_SAR",
3136                    debug=debug,
3137                )
3138
3139                c_SAR_balance -= x
3140                cached_value = self.balance(c_SAR, cached=True)
3141                fresh_value = self.balance(c_SAR, cached=False)
3142                if debug:
3143                    print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
3144                          c_SAR_balance)
3145                assert cached_value == c_SAR_balance
3146                assert fresh_value == c_SAR_balance
3147
3148                a_SAR_balance += x
3149                cached_value = self.balance(a_SAR, cached=True)
3150                fresh_value = self.balance(a_SAR, cached=False)
3151                if debug:
3152                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
3153                          a_SAR_balance)
3154                assert cached_value == a_SAR_balance
3155                assert fresh_value == a_SAR_balance
3156                i += 1
3157
3158            assert self.export_json("accounts-transfer-with-exchange-rates.json")
3159            assert self.save(f"accounts-transfer-with-exchange-rates.{self.ext()}")
3160
3161            # check & zakat with exchange rates for many cycles
3162
3163            for rate, values in {
3164                1: {
3165                    'in': [1000, 2000, 10000],
3166                    'exchanged': [100000, 200000, 1000000],
3167                    'out': [2500, 5000, 73140],
3168                },
3169                3.75: {
3170                    'in': [200, 1000, 5000],
3171                    'exchanged': [75000, 375000, 1875000],
3172                    'out': [1875, 9375, 137138],
3173                },
3174            }.items():
3175                a, b, c = values['in']
3176                m, n, o = values['exchanged']
3177                x, y, z = values['out']
3178                if debug:
3179                    print('rate', rate, 'values', values)
3180                for case in [
3181                    (a, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
3182                        {'safe': {0: {'below_nisab': x}}},
3183                    ], False, m),
3184                    (b, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
3185                        {'safe': {0: {'count': 1, 'total': y}}},
3186                    ], True, n),
3187                    (c, 'cave', ZakatTracker.time() - (ZakatTracker.TimeCycle() * 3), [
3188                        {'cave': {0: {'count': 3, 'total': z}}},
3189                    ], True, o),
3190                ]:
3191                    if debug:
3192                        print(f"############# check(rate: {rate}) #############")
3193                        print('case', case)
3194                    self.reset()
3195                    self.exchange(account=case[1], created=case[2], rate=rate)
3196                    self.track(unscaled_value=case[0], desc='test-check', account=case[1], logging=True, created=case[2])
3197                    assert self.snapshot()
3198
3199                    # assert self.nolock()
3200                    # history_size = len(self._vault['history'])
3201                    # print('history_size', history_size)
3202                    # assert history_size == 2
3203                    assert self.lock()
3204                    assert not self.nolock()
3205                    report = self.check(2.17, None, debug)
3206                    (valid, brief, plan) = report
3207                    if debug:
3208                        print('brief', brief)
3209                    assert valid == case[4]
3210                    assert case[5] == brief[0]
3211                    assert case[5] == brief[1]
3212
3213                    if debug:
3214                        pp().pprint(plan)
3215
3216                    for x in plan:
3217                        assert case[1] == x
3218                        if 'total' in case[3][0][x][0].keys():
3219                            assert case[3][0][x][0]['total'] == int(brief[2])
3220                            assert int(plan[x][0]['total']) == case[3][0][x][0]['total']
3221                            assert int(plan[x][0]['count']) == case[3][0][x][0]['count']
3222                        else:
3223                            assert plan[x][0]['below_nisab'] == case[3][0][x][0]['below_nisab']
3224                    if debug:
3225                        pp().pprint(report)
3226                    result = self.zakat(report, debug=debug)
3227                    if debug:
3228                        print('zakat-result', result, case[4])
3229                    assert result == case[4]
3230                    report = self.check(2.17, None, debug)
3231                    (valid, brief, plan) = report
3232                    assert valid is False
3233
3234            history_size = len(self._vault['history'])
3235            if debug:
3236                print('history_size', history_size)
3237            assert history_size == 3
3238            assert not self.nolock()
3239            assert self.recall(False, debug) is False
3240            self.free(self.lock())
3241            assert self.nolock()
3242
3243            for i in range(3, 0, -1):
3244                history_size = len(self._vault['history'])
3245                if debug:
3246                    print('history_size', history_size)
3247                assert history_size == i
3248                assert self.recall(False, debug) is True
3249
3250            assert self.nolock()
3251            assert self.recall(False, debug) is False
3252
3253            history_size = len(self._vault['history'])
3254            if debug:
3255                print('history_size', history_size)
3256            assert history_size == 0
3257
3258            account_size = len(self._vault['account'])
3259            if debug:
3260                print('account_size', account_size)
3261            assert account_size == 0
3262
3263            report_size = len(self._vault['report'])
3264            if debug:
3265                print('report_size', report_size)
3266            assert report_size == 0
3267
3268            assert self.nolock()
3269            return True
3270        except Exception as e:
3271            # pp().pprint(self._vault)
3272            assert self.export_json("test-snapshot.json")
3273            assert self.save(f"test-snapshot.{self.ext()}")
3274            raise e
def test(debug: bool = False):
3277def test(debug: bool = False):
3278    ledger = ZakatTracker("./zakat_test_db/zakat.camel")
3279    start = ZakatTracker.time()
3280    assert ledger.test(debug=debug)
3281    if debug:
3282        print("#########################")
3283        print("######## TEST DONE ########")
3284        print("#########################")
3285        print(ZakatTracker.duration_from_nanoseconds(ZakatTracker.time() - start))
3286        print("#########################")
class Action(enum.Enum):
76class Action(Enum):
77    CREATE = auto()
78    TRACK = auto()
79    LOG = auto()
80    SUB = auto()
81    ADD_FILE = auto()
82    REMOVE_FILE = auto()
83    BOX_TRANSFER = auto()
84    EXCHANGE = auto()
85    REPORT = auto()
86    ZAKAT = auto()
CREATE = <Action.CREATE: 1>
TRACK = <Action.TRACK: 2>
LOG = <Action.LOG: 3>
SUB = <Action.SUB: 4>
ADD_FILE = <Action.ADD_FILE: 5>
REMOVE_FILE = <Action.REMOVE_FILE: 6>
BOX_TRANSFER = <Action.BOX_TRANSFER: 7>
EXCHANGE = <Action.EXCHANGE: 8>
REPORT = <Action.REPORT: 9>
ZAKAT = <Action.ZAKAT: 10>
class JSONEncoder(json.encoder.JSONEncoder):
89class JSONEncoder(json.JSONEncoder):
90    def default(self, obj):
91        if isinstance(obj, Action) or isinstance(obj, MathOperation):
92            return obj.name  # Serialize as the enum member's name
93        elif isinstance(obj, Decimal):
94            return float(obj)
95        return super().default(obj)

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

Supports the following objects and types by default:

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

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

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

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

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

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

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

This server facilitates the following functionalities:

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

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

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

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

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

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

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

Returns: int: The available TCP port number.

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

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

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