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

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:
226    @staticmethod
227    def ZakatCut(x: float) -> float:
228        """
229        Calculates the Zakat amount due on an asset.
230
231        This function calculates the zakat amount due on a given asset value over one lunar year.
232        Zakat is an Islamic obligatory alms-giving, calculated as a fixed percentage of an individual's wealth
233        that exceeds a certain threshold (Nisab).
234
235        Parameters:
236        x: The total value of the asset on which Zakat is to be calculated.
237
238        Returns:
239        The amount of Zakat due on the asset, calculated as 2.5% of the asset's value.
240        """
241        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:
243    @staticmethod
244    def TimeCycle(days: int = 355) -> int:
245        """
246        Calculates the approximate duration of a lunar year in nanoseconds.
247
248        This function calculates the approximate duration of a lunar year based on the given number of days.
249        It converts the given number of days into nanoseconds for use in high-precision timing applications.
250
251        Parameters:
252        days: The number of days in a lunar year. Defaults to 355,
253              which is an approximation of the average length of a lunar year.
254
255        Returns:
256        The approximate duration of a lunar year in nanoseconds.
257        """
258        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:
260    @staticmethod
261    def Nisab(gram_price: float, gram_quantity: float = 595) -> float:
262        """
263        Calculate the total value of Nisab (a unit of weight in Islamic jurisprudence) based on the given price per gram.
264
265        This function calculates the Nisab value, which is the minimum threshold of wealth,
266        that makes an individual liable for paying Zakat.
267        The Nisab value is determined by the equivalent value of a specific amount
268        of gold or silver (currently 595 grams in silver) in the local currency.
269
270        Parameters:
271        - gram_price (float): The price per gram of Nisab.
272        - gram_quantity (float): The quantity of grams in a Nisab. Default is 595 grams of silver.
273
274        Returns:
275        - float: The total value of Nisab based on the given price per gram.
276        """
277        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:
279    @staticmethod
280    def ext() -> str:
281        """
282        Returns the file extension used by the ZakatTracker class.
283
284        Returns:
285        str: The file extension used by the ZakatTracker class, which is 'camel'.
286        """
287        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:
307    def path(self, path: str = None) -> str:
308        """
309        Set or get the path to the database file.
310
311        If no path is provided, the current path is returned.
312        If a path is provided, it is set as the new path.
313        The function also creates the necessary directories if the provided path is a file.
314
315        Parameters:
316        path (str): The new path to the database file. If not provided, the current path is returned.
317
318        Returns:
319        str: The current or new path to the database file.
320        """
321        if path is None:
322            return self._vault_path
323        self._vault_path = Path(path).resolve()
324        base_path = Path(path).resolve()
325        if base_path.is_file() or base_path.suffix:
326            base_path = base_path.parent
327        base_path.mkdir(parents=True, exist_ok=True)
328        self._base_path = base_path
329        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:
331    def base_path(self, *args) -> str:
332        """
333        Generate a base path by joining the provided arguments with the existing base path.
334
335        Parameters:
336        *args (str): Variable length argument list of strings to be joined with the base path.
337
338        Returns:
339        str: The generated base path. If no arguments are provided, the existing base path is returned.
340        """
341        if not args:
342            return str(self._base_path)
343        filtered_args = []
344        ignored_filename = None
345        for arg in args:
346            if Path(arg).suffix:
347                ignored_filename = arg
348            else:
349                filtered_args.append(arg)
350        base_path = Path(self._base_path)
351        full_path = base_path.joinpath(*filtered_args)
352        full_path.mkdir(parents=True, exist_ok=True)
353        if ignored_filename is not None:
354            return full_path.resolve() / ignored_filename  # Join with the ignored filename
355        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:
357    @staticmethod
358    def scale(x: float | int | Decimal, decimal_places: int = 2) -> int:
359        """
360        Scales a numerical value by a specified power of 10, returning an integer.
361
362        This function is designed to handle various numeric types (`float`, `int`, or `Decimal`) and
363        facilitate precise scaling operations, particularly useful in financial or scientific calculations.
364
365        Parameters:
366        x: The numeric value to scale. Can be a floating-point number, integer, or decimal.
367        decimal_places: The exponent for the scaling factor (10**y). Defaults to 2, meaning the input is scaled
368            by a factor of 100 (e.g., converts 1.23 to 123).
369
370        Returns:
371        The scaled value, rounded to the nearest integer.
372
373        Raises:
374        TypeError: If the input `x` is not a valid numeric type.
375
376        Examples:
377        >>> ZakatTracker.scale(3.14159)
378        314
379        >>> ZakatTracker.scale(1234, decimal_places=3)
380        1234000
381        >>> ZakatTracker.scale(Decimal("0.005"), decimal_places=4)
382        50
383        """
384        if not isinstance(x, (float, int, Decimal)):
385            raise TypeError("Input 'x' must be a float, int, or Decimal.")
386        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:
388    @staticmethod
389    def unscale(x: int, return_type: type = float, decimal_places: int = 2) -> float | Decimal:
390        """
391        Unscales an integer by a power of 10.
392
393        Parameters:
394        x: The integer to unscale.
395        return_type: The desired type for the returned value. Can be float, int, or Decimal. Defaults to float.
396        decimal_places: The power of 10 to use. Defaults to 2.
397
398        Returns:
399        The unscaled number, converted to the specified return_type.
400
401        Raises:
402        TypeError: If the return_type is not float or Decimal.
403        """
404        if return_type not in (float, Decimal):
405            raise TypeError(f'Invalid return_type({return_type}). Supported types are float, int, and Decimal.')
406        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:
422    def reset(self) -> None:
423        """
424        Reset the internal data structure to its initial state.
425
426        Parameters:
427        None
428
429        Returns:
430        None
431        """
432        self._vault = {
433            'account': {},
434            'exchange': {},
435            'history': {},
436            'lock': None,
437            'report': {},
438        }

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:
440    @staticmethod
441    def time(now: datetime = None) -> int:
442        """
443        Generates a timestamp based on the provided datetime object or the current datetime.
444
445        Parameters:
446        now (datetime, optional): The datetime object to generate the timestamp from.
447        If not provided, the current datetime is used.
448
449        Returns:
450        int: The timestamp in positive nanoseconds since the Unix epoch (January 1, 1970),
451            before 1970 will return in negative until 1000AD.
452        """
453        if now is None:
454            now = datetime.datetime.now()
455        ordinal_day = now.toordinal()
456        ns_in_day = (now - now.replace(hour=0, minute=0, second=0, microsecond=0)).total_seconds() * 10 ** 9
457        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'>:
459    @staticmethod
460    def time_to_datetime(ordinal_ns: int) -> datetime:
461        """
462        Converts an ordinal number (number of days since 1000-01-01) to a datetime object.
463
464        Parameters:
465        ordinal_ns (int): The ordinal number of days since 1000-01-01.
466
467        Returns:
468        datetime: The corresponding datetime object.
469        """
470        ordinal_day = ordinal_ns // 86_400_000_000_000 + 719_163
471        ns_in_day = ordinal_ns % 86_400_000_000_000
472        d = datetime.datetime.fromordinal(ordinal_day)
473        t = datetime.timedelta(seconds=ns_in_day // 10 ** 9)
474        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:
476    def clean_history(self, lock: int | None = None) -> int:
477        """
478        Cleans up the history of actions performed on the ZakatTracker instance.
479
480        Parameters:
481        lock (int, optional): The lock ID is used to clean up the empty history.
482            If not provided, it cleans up the empty history records for all locks.
483
484        Returns:
485        int: The number of locks cleaned up.
486        """
487        count = 0
488        if lock in self._vault['history']:
489            if len(self._vault['history'][lock]) <= 0:
490                count += 1
491                del self._vault['history'][lock]
492            return count
493        self.free(self.lock())
494        for lock in self._vault['history']:
495            if len(self._vault['history'][lock]) <= 0:
496                count += 1
497                del self._vault['history'][lock]
498        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:
536    def nolock(self) -> bool:
537        """
538        Check if the vault lock is currently not set.
539
540        Returns:
541        bool: True if the vault lock is not set, False otherwise.
542        """
543        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:
545    def lock(self) -> int:
546        """
547        Acquires a lock on the ZakatTracker instance.
548
549        Returns:
550        int: The lock ID. This ID can be used to release the lock later.
551        """
552        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:
554    def vault(self) -> dict:
555        """
556        Returns a copy of the internal vault dictionary.
557
558        This method is used to retrieve the current state of the ZakatTracker object.
559        It provides a snapshot of the internal data structure, allowing for further
560        processing or analysis.
561
562        Returns:
563        dict: A copy of the internal vault dictionary.
564        """
565        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]:
567    def stats(self) -> dict[str, tuple]:
568        """
569        Calculates and returns statistics about the object's data storage.
570
571        This method determines the size of the database file on disk and the
572        size of the data currently held in RAM (likely within a dictionary).
573        Both sizes are reported in bytes and in a human-readable format
574        (e.g., KB, MB).
575
576        Returns:
577        dict[str, tuple]: A dictionary containing the following statistics:
578
579            * 'database': A tuple with two elements:
580                - The database file size in bytes (int).
581                - The database file size in human-readable format (str).
582            * 'ram': A tuple with two elements:
583                - The RAM usage (dictionary size) in bytes (int).
584                - The RAM usage in human-readable format (str).
585
586        Example:
587        >>> stats = my_object.stats()
588        >>> print(stats['database'])
589        (256000, '250.0 KB')
590        >>> print(stats['ram'])
591        (12345, '12.1 KB')
592        """
593        ram_size = self.get_dict_size(self.vault())
594        file_size = os.path.getsize(self.path())
595        return {
596            'database': (file_size, self.human_readable_size(file_size)),
597            'ram': (ram_size, self.human_readable_size(ram_size)),
598        }

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]]:
600    def files(self) -> list[dict[str, str | int]]:
601        """
602        Retrieves information about files associated with this class.
603
604        This class method provides a standardized way to gather details about
605        files used by the class for storage, snapshots, and CSV imports.
606
607        Returns:
608        list[dict[str, str | int]]: A list of dictionaries, each containing information
609            about a specific file:
610
611            * type (str): The type of file ('database', 'snapshot', 'import_csv').
612            * path (str): The full file path.
613            * exists (bool): Whether the file exists on the filesystem.
614            * size (int): The file size in bytes (0 if the file doesn't exist).
615            * human_readable_size (str): A human-friendly representation of the file size (e.g., '10 KB', '2.5 MB').
616
617        Example:
618        ```
619        file_info = MyClass.files()
620        for info in file_info:
621            print(f"Type: {info['type']}, Exists: {info['exists']}, Size: {info['human_readable_size']}")
622        ```
623        """
624        result = []
625        for file_type, path in {
626            'database': self.path(),
627            'snapshot': self.snapshot_cache_path(),
628            'import_csv': self.import_csv_cache_path(),
629        }.items():
630            exists = os.path.exists(path)
631            size = os.path.getsize(path) if exists else 0
632            human_readable_size = self.human_readable_size(size) if exists else 0
633            result.append({
634                'type': file_type,
635                'path': path,
636                'exists': exists,
637                'size': size,
638                'human_readable_size': human_readable_size,
639            })
640        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:
642    def steps(self) -> dict:
643        """
644        Returns a copy of the history of steps taken in the ZakatTracker.
645
646        The history is a dictionary where each key is a unique identifier for a step,
647        and the corresponding value is a dictionary containing information about the step.
648
649        Returns:
650        dict: A copy of the history of steps taken in the ZakatTracker.
651        """
652        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:
654    def free(self, lock: int, auto_save: bool = True) -> bool:
655        """
656        Releases the lock on the database.
657
658        Parameters:
659        lock (int): The lock ID to be released.
660        auto_save (bool): Whether to automatically save the database after releasing the lock.
661
662        Returns:
663        bool: True if the lock is successfully released and (optionally) saved, False otherwise.
664        """
665        if lock == self._vault['lock']:
666            self._vault['lock'] = None
667            self.clean_history(lock)
668            if auto_save:
669                return self.save(self.path())
670            return True
671        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:
673    def account_exists(self, account) -> bool:
674        """
675        Check if the given account exists in the vault.
676
677        Parameters:
678        account (str): The account number to check.
679
680        Returns:
681        bool: True if the account exists, False otherwise.
682        """
683        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:
685    def box_size(self, account) -> int:
686        """
687        Calculate the size of the box for a specific account.
688
689        Parameters:
690        account (str): The account number for which the box size needs to be calculated.
691
692        Returns:
693        int: The size of the box for the given account. If the account does not exist, -1 is returned.
694        """
695        if self.account_exists(account):
696            return len(self._vault['account'][account]['box'])
697        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:
699    def log_size(self, account) -> int:
700        """
701        Get the size of the log for a specific account.
702
703        Parameters:
704        account (str): The account number for which the log size needs to be calculated.
705
706        Returns:
707        int: The size of the log for the given account. If the account does not exist, -1 is returned.
708        """
709        if self.account_exists(account):
710            return len(self._vault['account'][account]['log'])
711        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:
713    @staticmethod
714    def file_hash(file_path: str, algorithm: str = "blake2b") -> str:
715        """
716        Calculates the hash of a file using the specified algorithm.
717
718        Parameters:
719        file_path (str): The path to the file.
720        algorithm (str, optional): The hashing algorithm to use. Defaults to "blake2b".
721
722        Returns:
723        str: The hexadecimal representation of the file's hash.
724        """
725        hash_obj = hashlib.new(algorithm)  # Create the hash object
726        with open(file_path, "rb") as f:  # Open file in binary mode for reading
727            for chunk in iter(lambda: f.read(4096), b""):  # Read file in chunks
728                hash_obj.update(chunk)
729        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):
731    def snapshot_cache_path(self):
732        """
733        Generate the path for the cache file used to store snapshots.
734
735        The cache file is a camel file that stores the timestamps of the snapshots.
736        The file name is derived from the main database file name by replacing the ".camel" extension with ".snapshots.camel".
737
738        Returns:
739        str: The path to the cache file.
740        """
741        path = str(self.path())
742        ext = self.ext()
743        ext_len = len(ext)
744        if path.endswith(f'.{ext}'):
745            path = path[:-ext_len - 1]
746        _, filename = os.path.split(path + f'.snapshots.{ext}')
747        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:
749    def snapshot(self) -> bool:
750        """
751        This function creates a snapshot of the current database state.
752
753        The function calculates the hash of the current database file and checks if a snapshot with the same hash already exists.
754        If a snapshot with the same hash exists, the function returns True without creating a new snapshot.
755        If a snapshot with the same hash does not exist, the function creates a new snapshot by saving the current database state
756        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.
757
758        Parameters:
759        None
760
761        Returns:
762        bool: True if a snapshot with the same hash already exists or if the snapshot is successfully created. False if the snapshot creation fails.
763        """
764        current_hash = self.file_hash(self.path())
765        cache: dict[str, int] = {}  # hash: time_ns
766        try:
767            with open(self.snapshot_cache_path(), 'r') as stream:
768                cache = camel.load(stream.read())
769        except:
770            pass
771        if current_hash in cache:
772            return True
773        time = time_ns()
774        cache[current_hash] = time
775        if not self.save(self.base_path('snapshots', f'{time}.{self.ext()}')):
776            return False
777        with open(self.snapshot_cache_path(), 'w') as stream:
778            stream.write(camel.dump(cache))
779        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]]:
781    def snapshots(self, hide_missing: bool = True, verified_hash_only: bool = False) \
782            -> dict[int, tuple[str, str, bool]]:
783        """
784        Retrieve a dictionary of snapshots, with their respective hashes, paths, and existence status.
785
786        Parameters:
787        - hide_missing (bool): If True, only include snapshots that exist in the dictionary. Default is True.
788        - verified_hash_only (bool): If True, only include snapshots with a valid hash. Default is False.
789
790        Returns:
791        - dict[int, tuple[str, str, bool]]: A dictionary where the keys are the timestamps of the snapshots,
792        and the values are tuples containing the snapshot's hash, path, and existence status.
793        """
794        cache: dict[str, int] = {}  # hash: time_ns
795        try:
796            with open(self.snapshot_cache_path(), 'r') as stream:
797                cache = camel.load(stream.read())
798        except:
799            pass
800        if not cache:
801            return {}
802        result: dict[int, tuple[str, str, bool]] = {}  # time_ns: (hash, path, exists)
803        for file_hash, ref in cache.items():
804            path = self.base_path('snapshots', f'{ref}.{self.ext()}')
805            exists = os.path.exists(path)
806            valid_hash = self.file_hash(path) == file_hash if verified_hash_only else True
807            if (verified_hash_only and not valid_hash) or (verified_hash_only and not exists):
808                continue
809            if exists or not hide_missing:
810                result[ref] = (file_hash, path, exists)
811        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:
813    def recall(self, dry=True, debug=False) -> bool:
814        """
815        Revert the last operation.
816
817        Parameters:
818        dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
819        debug (bool): If True, the function will print debug information. Default is False.
820
821        Returns:
822        bool: True if the operation was successful, False otherwise.
823        """
824        if not self.nolock() or len(self._vault['history']) == 0:
825            return False
826        if len(self._vault['history']) <= 0:
827            return False
828        ref = sorted(self._vault['history'].keys())[-1]
829        if debug:
830            print('recall', ref)
831        memory = self._vault['history'][ref]
832        if debug:
833            print(type(memory), 'memory', memory)
834        limit = len(memory) + 1
835        sub_positive_log_negative = 0
836        for i in range(-1, -limit, -1):
837            x = memory[i]
838            if debug:
839                print(type(x), x)
840            match x['action']:
841                case Action.CREATE:
842                    if x['account'] is not None:
843                        if self.account_exists(x['account']):
844                            if debug:
845                                print('account', self._vault['account'][x['account']])
846                            assert len(self._vault['account'][x['account']]['box']) == 0
847                            assert self._vault['account'][x['account']]['balance'] == 0
848                            assert self._vault['account'][x['account']]['count'] == 0
849                            if dry:
850                                continue
851                            del self._vault['account'][x['account']]
852
853                case Action.TRACK:
854                    if x['account'] is not None:
855                        if self.account_exists(x['account']):
856                            if dry:
857                                continue
858                            self._vault['account'][x['account']]['balance'] -= x['value']
859                            self._vault['account'][x['account']]['count'] -= 1
860                            del self._vault['account'][x['account']]['box'][x['ref']]
861
862                case Action.LOG:
863                    if x['account'] is not None:
864                        if self.account_exists(x['account']):
865                            if x['ref'] in self._vault['account'][x['account']]['log']:
866                                if dry:
867                                    continue
868                                if sub_positive_log_negative == -x['value']:
869                                    self._vault['account'][x['account']]['count'] -= 1
870                                    sub_positive_log_negative = 0
871                                box_ref = self._vault['account'][x['account']]['log'][x['ref']]['ref']
872                                if not box_ref is None:
873                                    assert self.box_exists(x['account'], box_ref)
874                                    box_value = self._vault['account'][x['account']]['log'][x['ref']]['value']
875                                    assert box_value < 0
876
877                                    try:
878                                        self._vault['account'][x['account']]['box'][box_ref]['rest'] += -box_value
879                                    except TypeError:
880                                        self._vault['account'][x['account']]['box'][box_ref]['rest'] += Decimal(
881                                            -box_value)
882
883                                    try:
884                                        self._vault['account'][x['account']]['balance'] += -box_value
885                                    except TypeError:
886                                        self._vault['account'][x['account']]['balance'] += Decimal(-box_value)
887
888                                    self._vault['account'][x['account']]['count'] -= 1
889                                del self._vault['account'][x['account']]['log'][x['ref']]
890
891                case Action.SUB:
892                    if x['account'] is not None:
893                        if self.account_exists(x['account']):
894                            if x['ref'] in self._vault['account'][x['account']]['box']:
895                                if dry:
896                                    continue
897                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] += x['value']
898                                self._vault['account'][x['account']]['balance'] += x['value']
899                                sub_positive_log_negative = x['value']
900
901                case Action.ADD_FILE:
902                    if x['account'] is not None:
903                        if self.account_exists(x['account']):
904                            if x['ref'] in self._vault['account'][x['account']]['log']:
905                                if x['file'] in self._vault['account'][x['account']]['log'][x['ref']]['file']:
906                                    if dry:
907                                        continue
908                                    del self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']]
909
910                case Action.REMOVE_FILE:
911                    if x['account'] is not None:
912                        if self.account_exists(x['account']):
913                            if x['ref'] in self._vault['account'][x['account']]['log']:
914                                if dry:
915                                    continue
916                                self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']] = x['value']
917
918                case Action.BOX_TRANSFER:
919                    if x['account'] is not None:
920                        if self.account_exists(x['account']):
921                            if x['ref'] in self._vault['account'][x['account']]['box']:
922                                if dry:
923                                    continue
924                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] -= x['value']
925
926                case Action.EXCHANGE:
927                    if x['account'] is not None:
928                        if x['account'] in self._vault['exchange']:
929                            if x['ref'] in self._vault['exchange'][x['account']]:
930                                if dry:
931                                    continue
932                                del self._vault['exchange'][x['account']][x['ref']]
933
934                case Action.REPORT:
935                    if x['ref'] in self._vault['report']:
936                        if dry:
937                            continue
938                        del self._vault['report'][x['ref']]
939
940                case Action.ZAKAT:
941                    if x['account'] is not None:
942                        if self.account_exists(x['account']):
943                            if x['ref'] in self._vault['account'][x['account']]['box']:
944                                if x['key'] in self._vault['account'][x['account']]['box'][x['ref']]:
945                                    if dry:
946                                        continue
947                                    match x['math']:
948                                        case MathOperation.ADDITION:
949                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] -= x[
950                                                'value']
951                                        case MathOperation.EQUAL:
952                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] = x['value']
953                                        case MathOperation.SUBTRACTION:
954                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] += x[
955                                                'value']
956
957        if not dry:
958            del self._vault['history'][ref]
959        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:
961    def ref_exists(self, account: str, ref_type: str, ref: int) -> bool:
962        """
963        Check if a specific reference (transaction) exists in the vault for a given account and reference type.
964
965        Parameters:
966        account (str): The account number for which to check the existence of the reference.
967        ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
968        ref (int): The reference (transaction) number to check for existence.
969
970        Returns:
971        bool: True if the reference exists for the given account and reference type, False otherwise.
972        """
973        if account in self._vault['account']:
974            return ref in self._vault['account'][account][ref_type]
975        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:
977    def box_exists(self, account: str, ref: int) -> bool:
978        """
979        Check if a specific box (transaction) exists in the vault for a given account and reference.
980
981        Parameters:
982        - account (str): The account number for which to check the existence of the box.
983        - ref (int): The reference (transaction) number to check for existence.
984
985        Returns:
986        - bool: True if the box exists for the given account and reference, False otherwise.
987        """
988        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:
 990    def track(self, unscaled_value: float | int | Decimal = 0, desc: str = '', account: str = 1, logging: bool = True,
 991              created: int = None,
 992              debug: bool = False) -> int:
 993        """
 994        This function tracks a transaction for a specific account.
 995
 996        Parameters:
 997        unscaled_value (float | int | Decimal): The value of the transaction. Default is 0.
 998        desc (str): The description of the transaction. Default is an empty string.
 999        account (str): The account for which the transaction is being tracked. Default is '1'.
1000        logging (bool): Whether to log the transaction. Default is True.
1001        created (int): The timestamp of the transaction. If not provided, it will be generated. Default is None.
1002        debug (bool): Whether to print debug information. Default is False.
1003
1004        Returns:
1005        int: The timestamp of the transaction.
1006
1007        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.
1008
1009        Raises:
1010        ValueError: The log transaction happened again in the same nanosecond time.
1011        ValueError: The box transaction happened again in the same nanosecond time.
1012        """
1013        if debug:
1014            print('track', f'unscaled_value={unscaled_value}, debug={debug}')
1015        if created is None:
1016            created = self.time()
1017        no_lock = self.nolock()
1018        self.lock()
1019        if not self.account_exists(account):
1020            if debug:
1021                print(f"account {account} created")
1022            self._vault['account'][account] = {
1023                'balance': 0,
1024                'box': {},
1025                'count': 0,
1026                'log': {},
1027                'hide': False,
1028                'zakatable': True,
1029            }
1030            self._step(Action.CREATE, account)
1031        if unscaled_value == 0:
1032            if no_lock:
1033                self.free(self.lock())
1034            return 0
1035        value = self.scale(unscaled_value)
1036        if logging:
1037            self._log(value=value, desc=desc, account=account, created=created, ref=None, debug=debug)
1038        if debug:
1039            print('create-box', created)
1040        if self.box_exists(account, created):
1041            raise ValueError(f"The box transaction happened again in the same nanosecond time({created}).")
1042        if debug:
1043            print('created-box', created)
1044        self._vault['account'][account]['box'][created] = {
1045            'capital': value,
1046            'count': 0,
1047            'last': 0,
1048            'rest': value,
1049            'total': 0,
1050        }
1051        self._step(Action.TRACK, account, ref=created, value=value)
1052        if no_lock:
1053            self.free(self.lock())
1054        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:
1056    def log_exists(self, account: str, ref: int) -> bool:
1057        """
1058        Checks if a specific transaction log entry exists for a given account.
1059
1060        Parameters:
1061        account (str): The account number associated with the transaction log.
1062        ref (int): The reference to the transaction log entry.
1063
1064        Returns:
1065        bool: True if the transaction log entry exists, False otherwise.
1066        """
1067        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:
1115    def exchange(self, account, created: int = None, rate: float = None, description: str = None,
1116                 debug: bool = False) -> dict:
1117        """
1118        This method is used to record or retrieve exchange rates for a specific account.
1119
1120        Parameters:
1121        - account (str): The account number for which the exchange rate is being recorded or retrieved.
1122        - created (int): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
1123        - rate (float): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
1124        - description (str): A description of the exchange rate.
1125        - debug (bool): Whether to print debug information. Default is False.
1126
1127        Returns:
1128        - dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found,
1129        it returns a dictionary with default values for the rate and description.
1130        """
1131        if debug:
1132            print('exchange', f'debug={debug}')
1133        if created is None:
1134            created = self.time()
1135        no_lock = self.nolock()
1136        self.lock()
1137        if rate is not None:
1138            if rate <= 0:
1139                return dict()
1140            if account not in self._vault['exchange']:
1141                self._vault['exchange'][account] = {}
1142            if len(self._vault['exchange'][account]) == 0 and rate <= 1:
1143                return {"time": created, "rate": 1, "description": None}
1144            self._vault['exchange'][account][created] = {"rate": rate, "description": description}
1145            self._step(Action.EXCHANGE, account, ref=created, value=rate)
1146            if no_lock:
1147                self.free(self.lock())
1148            if debug:
1149                print("exchange-created-1",
1150                      f'account: {account}, created: {created}, rate:{rate}, description:{description}')
1151
1152        if account in self._vault['exchange']:
1153            valid_rates = [(ts, r) for ts, r in self._vault['exchange'][account].items() if ts <= created]
1154            if valid_rates:
1155                latest_rate = max(valid_rates, key=lambda x: x[0])
1156                if debug:
1157                    print("exchange-read-1",
1158                          f'account: {account}, created: {created}, rate:{rate}, description:{description}',
1159                          'latest_rate', latest_rate)
1160                result = latest_rate[1]
1161                result['time'] = latest_rate[0]
1162                return result  # إرجاع قاموس يحتوي على المعدل والوصف
1163        if debug:
1164            print("exchange-read-0", f'account: {account}, created: {created}, rate:{rate}, description:{description}')
1165        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:
1167    @staticmethod
1168    def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
1169        """
1170        This function calculates the exchanged amount of a currency.
1171
1172        Args:
1173            x (float): The original amount of the currency.
1174            x_rate (float): The exchange rate of the original currency.
1175            y_rate (float): The exchange rate of the target currency.
1176
1177        Returns:
1178            float: The exchanged amount of the target currency.
1179        """
1180        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:
1182    def exchanges(self) -> dict:
1183        """
1184        Retrieve the recorded exchange rates for all accounts.
1185
1186        Parameters:
1187        None
1188
1189        Returns:
1190        dict: A dictionary containing all recorded exchange rates.
1191        The keys are account names or numbers, and the values are dictionaries containing the exchange rates.
1192        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
1193        """
1194        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:
1196    def accounts(self) -> dict:
1197        """
1198        Returns a dictionary containing account numbers as keys and their respective balances as values.
1199
1200        Parameters:
1201        None
1202
1203        Returns:
1204        dict: A dictionary where keys are account numbers and values are their respective balances.
1205        """
1206        result = {}
1207        for i in self._vault['account']:
1208            result[i] = self._vault['account'][i]['balance']
1209        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:
1211    def boxes(self, account) -> dict:
1212        """
1213        Retrieve the boxes (transactions) associated with a specific account.
1214
1215        Parameters:
1216        account (str): The account number for which to retrieve the boxes.
1217
1218        Returns:
1219        dict: A dictionary containing the boxes associated with the given account.
1220        If the account does not exist, an empty dictionary is returned.
1221        """
1222        if self.account_exists(account):
1223            return self._vault['account'][account]['box']
1224        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:
1226    def logs(self, account) -> dict:
1227        """
1228        Retrieve the logs (transactions) associated with a specific account.
1229
1230        Parameters:
1231        account (str): The account number for which to retrieve the logs.
1232
1233        Returns:
1234        dict: A dictionary containing the logs associated with the given account.
1235        If the account does not exist, an empty dictionary is returned.
1236        """
1237        if self.account_exists(account):
1238            return self._vault['account'][account]['log']
1239        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, weekday: WeekDay = <WeekDay.Friday: 4>, debug: bool = False):
1241    def daily_logs(self, weekday: WeekDay = WeekDay.Friday, debug: bool = False):
1242        """
1243        Retrieve the daily logs (transactions) from all accounts.
1244
1245        The function groups the logs by day, month, and year, and calculates the total value for each group.
1246        It returns a dictionary where the keys are the timestamps of the daily groups,
1247        and the values are dictionaries containing the total value and the logs for that group.
1248
1249        Parameters:
1250        weekday (WeekDay): Select the weekday is collected for the week data. Default is WeekDay.Friday.
1251        debug (bool): Whether to print debug information. Default is False.
1252
1253        Returns:
1254        dict: A dictionary containing the daily logs.
1255
1256        Example:
1257        >>> tracker = ZakatTracker()
1258        >>> tracker.sub(51, 'desc', 'account1')
1259        >>> tracker.track(100, 'desc', 'account2')
1260        >>> tracker.daily_logs()
1261        {
1262            'daily': {
1263                '2024-06-30': {
1264                    'positive': 100,
1265                    'negative': 51,
1266                    'total': 99,
1267                    'rows': [
1268                        {
1269                            'account': 'account1',
1270                            'desc': 'desc',
1271                            'file': {},
1272                            'ref': None,
1273                            'value': -51,
1274                            'time': 1690977015000000000,
1275                            'transfer': False,
1276                        },
1277                        {
1278                            'account': 'account2',
1279                            'desc': 'desc',
1280                            'file': {},
1281                            'ref': None,
1282                            'value': 100,
1283                            'time': 1690977015000000000,
1284                            'transfer': False,
1285                        },
1286                    ],
1287                },
1288            },
1289            'weekly': {
1290                datetime: {
1291                    'positive': 100,
1292                    'negative': 51,
1293                    'total': 99,
1294                },
1295            },
1296            'monthly': {
1297                '2024-06': {
1298                    'positive': 100,
1299                    'negative': 51,
1300                    'total': 99,
1301                },
1302            },
1303            'yearly': {
1304                2024: {
1305                    'positive': 100,
1306                    'negative': 51,
1307                    'total': 99,
1308                },
1309            },
1310        }
1311        """
1312        logs = {}
1313        for account in self.accounts():
1314            for k, v in self.logs(account).items():
1315                v['time'] = k
1316                v['account'] = account
1317                if k not in logs:
1318                    logs[k] = []
1319                logs[k].append(v)
1320        if debug:
1321            print('logs', logs)
1322        y = {
1323            'daily': {},
1324            'weekly': {},
1325            'monthly': {},
1326            'yearly': {},
1327        }
1328        for i in sorted(logs, reverse=True):
1329            dt = self.time_to_datetime(i)
1330            daily = f'{dt.year}-{dt.month:02d}-{dt.day:02d}'
1331            weekly = dt - timedelta(days=weekday.value)
1332            monthly = f'{dt.year}-{dt.month:02d}'
1333            yearly = dt.year
1334            # daily
1335            if daily not in y['daily']:
1336                y['daily'][daily] = {
1337                    'positive': 0,
1338                    'negative': 0,
1339                    'total': 0,
1340                    'rows': [],
1341                }
1342            transfer = len(logs[i]) > 1
1343            if debug:
1344                print('logs[i]', logs[i])
1345            for z in logs[i]:
1346                if debug:
1347                    print('z', z)
1348                # daily
1349                value = z['value']
1350                if value > 0:
1351                    y['daily'][daily]['positive'] += value
1352                else:
1353                    y['daily'][daily]['negative'] += -value
1354                y['daily'][daily]['total'] += value
1355                z['transfer'] = transfer
1356                y['daily'][daily]['rows'].append(z)
1357                # weekly
1358                if weekly not in y['weekly']:
1359                    y['weekly'][weekly] = {
1360                        'positive': 0,
1361                        'negative': 0,
1362                        'total': 0,
1363                    }
1364                if value > 0:
1365                    y['weekly'][weekly]['positive'] += value
1366                else:
1367                    y['weekly'][weekly]['negative'] += -value
1368                y['weekly'][weekly]['total'] += value
1369                # monthly
1370                if monthly not in y['monthly']:
1371                    y['monthly'][monthly] = {
1372                        'positive': 0,
1373                        'negative': 0,
1374                        'total': 0,
1375                    }
1376                if value > 0:
1377                    y['monthly'][monthly]['positive'] += value
1378                else:
1379                    y['monthly'][monthly]['negative'] += -value
1380                y['monthly'][monthly]['total'] += value
1381                # yearly
1382                if yearly not in y['yearly']:
1383                    y['yearly'][yearly] = {
1384                        'positive': 0,
1385                        'negative': 0,
1386                        'total': 0,
1387                    }
1388                if value > 0:
1389                    y['yearly'][yearly]['positive'] += value
1390                else:
1391                    y['yearly'][yearly]['negative'] += -value
1392                y['yearly'][yearly]['total'] += value
1393        if debug:
1394            print('y', y)
1395        return y

Retrieve the daily logs (transactions) from all accounts.

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

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

Returns: dict: A dictionary containing the daily logs.

Example:

>>> tracker = ZakatTracker()
>>> tracker.sub(51, 'desc', 'account1')
>>> tracker.track(100, 'desc', 'account2')
>>> tracker.daily_logs()
{
    'daily': {
        '2024-06-30': {
            'positive': 100,
            'negative': 51,
            'total': 99,
            'rows': [
                {
                    'account': 'account1',
                    'desc': 'desc',
                    'file': {},
                    'ref': None,
                    'value': -51,
                    'time': 1690977015000000000,
                    'transfer': False,
                },
                {
                    'account': 'account2',
                    'desc': 'desc',
                    'file': {},
                    'ref': None,
                    'value': 100,
                    'time': 1690977015000000000,
                    'transfer': False,
                },
            ],
        },
    },
    'weekly': {
        datetime: {
            'positive': 100,
            'negative': 51,
            'total': 99,
        },
    },
    'monthly': {
        '2024-06': {
            'positive': 100,
            'negative': 51,
            'total': 99,
        },
    },
    'yearly': {
        2024: {
            'positive': 100,
            'negative': 51,
            'total': 99,
        },
    },
}
def add_file(self, account: str, ref: int, path: str) -> int:
1397    def add_file(self, account: str, ref: int, path: str) -> int:
1398        """
1399        Adds a file reference to a specific transaction log entry in the vault.
1400
1401        Parameters:
1402        account (str): The account number associated with the transaction log.
1403        ref (int): The reference to the transaction log entry.
1404        path (str): The path of the file to be added.
1405
1406        Returns:
1407        int: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
1408        """
1409        if self.account_exists(account):
1410            if ref in self._vault['account'][account]['log']:
1411                file_ref = self.time()
1412                self._vault['account'][account]['log'][ref]['file'][file_ref] = path
1413                no_lock = self.nolock()
1414                self.lock()
1415                self._step(Action.ADD_FILE, account, ref=ref, file=file_ref)
1416                if no_lock:
1417                    self.free(self.lock())
1418                return file_ref
1419        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:
1421    def remove_file(self, account: str, ref: int, file_ref: int) -> bool:
1422        """
1423        Removes a file reference from a specific transaction log entry in the vault.
1424
1425        Parameters:
1426        account (str): The account number associated with the transaction log.
1427        ref (int): The reference to the transaction log entry.
1428        file_ref (int): The reference of the file to be removed.
1429
1430        Returns:
1431        bool: True if the file reference is successfully removed, False otherwise.
1432        """
1433        if self.account_exists(account):
1434            if ref in self._vault['account'][account]['log']:
1435                if file_ref in self._vault['account'][account]['log'][ref]['file']:
1436                    x = self._vault['account'][account]['log'][ref]['file'][file_ref]
1437                    del self._vault['account'][account]['log'][ref]['file'][file_ref]
1438                    no_lock = self.nolock()
1439                    self.lock()
1440                    self._step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x)
1441                    if no_lock:
1442                        self.free(self.lock())
1443                    return True
1444        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:
1446    def balance(self, account: str = 1, cached: bool = True) -> int:
1447        """
1448        Calculate and return the balance of a specific account.
1449
1450        Parameters:
1451        account (str): The account number. Default is '1'.
1452        cached (bool): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
1453
1454        Returns:
1455        int: The balance of the account.
1456
1457        Note:
1458        If cached is True, the function returns the cached balance.
1459        If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
1460        """
1461        if cached:
1462            return self._vault['account'][account]['balance']
1463        x = 0
1464        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:
1466    def hide(self, account, status: bool = None) -> bool:
1467        """
1468        Check or set the hide status of a specific account.
1469
1470        Parameters:
1471        account (str): The account number.
1472        status (bool, optional): The new hide status. If not provided, the function will return the current status.
1473
1474        Returns:
1475        bool: The current or updated hide status of the account.
1476
1477        Raises:
1478        None
1479
1480        Example:
1481        >>> tracker = ZakatTracker()
1482        >>> ref = tracker.track(51, 'desc', 'account1')
1483        >>> tracker.hide('account1')  # Set the hide status of 'account1' to True
1484        False
1485        >>> tracker.hide('account1', True)  # Set the hide status of 'account1' to True
1486        True
1487        >>> tracker.hide('account1')  # Get the hide status of 'account1' by default
1488        True
1489        >>> tracker.hide('account1', False)
1490        False
1491        """
1492        if self.account_exists(account):
1493            if status is None:
1494                return self._vault['account'][account]['hide']
1495            self._vault['account'][account]['hide'] = status
1496            return status
1497        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:
1499    def zakatable(self, account, status: bool = None) -> bool:
1500        """
1501        Check or set the zakatable status of a specific account.
1502
1503        Parameters:
1504        account (str): The account number.
1505        status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
1506
1507        Returns:
1508        bool: The current or updated zakatable status of the account.
1509
1510        Raises:
1511        None
1512
1513        Example:
1514        >>> tracker = ZakatTracker()
1515        >>> ref = tracker.track(51, 'desc', 'account1')
1516        >>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
1517        True
1518        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
1519        True
1520        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
1521        True
1522        >>> tracker.zakatable('account1', False)
1523        False
1524        """
1525        if self.account_exists(account):
1526            if status is None:
1527                return self._vault['account'][account]['zakatable']
1528            self._vault['account'][account]['zakatable'] = status
1529            return status
1530        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:
1532    def sub(self, unscaled_value: float | int | Decimal, desc: str = '', account: str = 1, created: int = None,
1533            debug: bool = False) \
1534            -> tuple[
1535                   int,
1536                   list[
1537                       tuple[int, int],
1538                   ],
1539               ] | tuple:
1540        """
1541        Subtracts a specified value from an account's balance.
1542
1543        Parameters:
1544        unscaled_value (float | int | Decimal): The amount to be subtracted.
1545        desc (str): A description for the transaction. Defaults to an empty string.
1546        account (str): The account from which the value will be subtracted. Defaults to '1'.
1547        created (int): The timestamp of the transaction. If not provided, the current timestamp will be used.
1548        debug (bool): A flag indicating whether to print debug information. Defaults to False.
1549
1550        Returns:
1551        tuple: A tuple containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
1552
1553        If the amount to subtract is greater than the account's balance,
1554        the remaining amount will be transferred to a new transaction with a negative value.
1555
1556        Raises:
1557        ValueError: The box transaction happened again in the same nanosecond time.
1558        ValueError: The log transaction happened again in the same nanosecond time.
1559        """
1560        if debug:
1561            print('sub', f'debug={debug}')
1562        if unscaled_value < 0:
1563            return tuple()
1564        if unscaled_value == 0:
1565            ref = self.track(unscaled_value, '', account)
1566            return ref, ref
1567        if created is None:
1568            created = self.time()
1569        no_lock = self.nolock()
1570        self.lock()
1571        self.track(0, '', account)
1572        value = self.scale(unscaled_value)
1573        self._log(value=-value, desc=desc, account=account, created=created, ref=None, debug=debug)
1574        ids = sorted(self._vault['account'][account]['box'].keys())
1575        limit = len(ids) + 1
1576        target = value
1577        if debug:
1578            print('ids', ids)
1579        ages = []
1580        for i in range(-1, -limit, -1):
1581            if target == 0:
1582                break
1583            j = ids[i]
1584            if debug:
1585                print('i', i, 'j', j)
1586            rest = self._vault['account'][account]['box'][j]['rest']
1587            if rest >= target:
1588                self._vault['account'][account]['box'][j]['rest'] -= target
1589                self._step(Action.SUB, account, ref=j, value=target)
1590                ages.append((j, target))
1591                target = 0
1592                break
1593            elif target > rest > 0:
1594                chunk = rest
1595                target -= chunk
1596                self._step(Action.SUB, account, ref=j, value=chunk)
1597                ages.append((j, chunk))
1598                self._vault['account'][account]['box'][j]['rest'] = 0
1599        if target > 0:
1600            self.track(
1601                unscaled_value=self.unscale(-target),
1602                desc=desc,
1603                account=account,
1604                logging=False,
1605                created=created,
1606            )
1607            ages.append((created, target))
1608        if no_lock:
1609            self.free(self.lock())
1610        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]:
1612    def transfer(self, unscaled_amount: float | int | Decimal, from_account: str, to_account: str, desc: str = '',
1613                 created: int = None,
1614                 debug: bool = False) -> list[int]:
1615        """
1616        Transfers a specified value from one account to another.
1617
1618        Parameters:
1619        unscaled_amount (float | int | Decimal): The amount to be transferred.
1620        from_account (str): The account from which the value will be transferred.
1621        to_account (str): The account to which the value will be transferred.
1622        desc (str, optional): A description for the transaction. Defaults to an empty string.
1623        created (int, optional): The timestamp of the transaction. If not provided, the current timestamp will be used.
1624        debug (bool): A flag indicating whether to print debug information. Defaults to False.
1625
1626        Returns:
1627        list[int]: A list of timestamps corresponding to the transactions made during the transfer.
1628
1629        Raises:
1630        ValueError: Transfer to the same account is forbidden.
1631        ValueError: The box transaction happened again in the same nanosecond time.
1632        ValueError: The log transaction happened again in the same nanosecond time.
1633        """
1634        if debug:
1635            print('transfer', f'debug={debug}')
1636        if from_account == to_account:
1637            raise ValueError(f'Transfer to the same account is forbidden. {to_account}')
1638        if unscaled_amount <= 0:
1639            return []
1640        if created is None:
1641            created = self.time()
1642        (_, ages) = self.sub(unscaled_amount, desc, from_account, created, debug=debug)
1643        times = []
1644        source_exchange = self.exchange(from_account, created)
1645        target_exchange = self.exchange(to_account, created)
1646
1647        if debug:
1648            print('ages', ages)
1649
1650        for age, value in ages:
1651            target_amount = int(self.exchange_calc(value, source_exchange['rate'], target_exchange['rate']))
1652            if debug:
1653                print('target_amount', target_amount)
1654            # Perform the transfer
1655            if self.box_exists(to_account, age):
1656                if debug:
1657                    print('box_exists', age)
1658                capital = self._vault['account'][to_account]['box'][age]['capital']
1659                rest = self._vault['account'][to_account]['box'][age]['rest']
1660                if debug:
1661                    print(
1662                        f"Transfer(loop) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1663                selected_age = age
1664                if rest + target_amount > capital:
1665                    self._vault['account'][to_account]['box'][age]['capital'] += target_amount
1666                    selected_age = ZakatTracker.time()
1667                self._vault['account'][to_account]['box'][age]['rest'] += target_amount
1668                self._step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount)
1669                y = self._log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account,
1670                              created=None, ref=None, debug=debug)
1671                times.append((age, y))
1672                continue
1673            if debug:
1674                print(
1675                    f"Transfer(func) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1676            y = self.track(
1677                unscaled_value=self.unscale(int(target_amount)),
1678                desc=desc,
1679                account=to_account,
1680                logging=True,
1681                created=age,
1682                debug=debug,
1683            )
1684            times.append(y)
1685        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:
1687    def check(self,
1688              silver_gram_price: float,
1689              unscaled_nisab: float | int | Decimal = None,
1690              debug: bool = False,
1691              now: int = None,
1692              cycle: float = None) -> tuple:
1693        """
1694        Check the eligibility for Zakat based on the given parameters.
1695
1696        Parameters:
1697        silver_gram_price (float): The price of a gram of silver.
1698        unscaled_nisab (float | int | Decimal): The minimum amount of wealth required for Zakat. If not provided,
1699                        it will be calculated based on the silver_gram_price.
1700        debug (bool): Flag to enable debug mode.
1701        now (int): The current timestamp. If not provided, it will be calculated using ZakatTracker.time().
1702        cycle (float): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
1703
1704        Returns:
1705        tuple: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics,
1706        and a dictionary containing the Zakat plan.
1707        """
1708        if debug:
1709            print('check', f'debug={debug}')
1710        if now is None:
1711            now = self.time()
1712        if cycle is None:
1713            cycle = ZakatTracker.TimeCycle()
1714        if unscaled_nisab is None:
1715            unscaled_nisab = ZakatTracker.Nisab(silver_gram_price)
1716        nisab = self.scale(unscaled_nisab)
1717        plan = {}
1718        below_nisab = 0
1719        brief = [0, 0, 0]
1720        valid = False
1721        if debug:
1722            print('exchanges', self.exchanges())
1723        for x in self._vault['account']:
1724            if not self.zakatable(x):
1725                continue
1726            _box = self._vault['account'][x]['box']
1727            _log = self._vault['account'][x]['log']
1728            limit = len(_box) + 1
1729            ids = sorted(self._vault['account'][x]['box'].keys())
1730            for i in range(-1, -limit, -1):
1731                j = ids[i]
1732                rest = float(_box[j]['rest'])
1733                if rest <= 0:
1734                    continue
1735                exchange = self.exchange(x, created=self.time())
1736                rest = ZakatTracker.exchange_calc(rest, float(exchange['rate']), 1)
1737                brief[0] += rest
1738                index = limit + i - 1
1739                epoch = (now - j) / cycle
1740                if debug:
1741                    print(f"Epoch: {epoch}", _box[j])
1742                if _box[j]['last'] > 0:
1743                    epoch = (now - _box[j]['last']) / cycle
1744                if debug:
1745                    print(f"Epoch: {epoch}")
1746                epoch = floor(epoch)
1747                if debug:
1748                    print(f"Epoch: {epoch}", type(epoch), epoch == 0, 1 - epoch, epoch)
1749                if epoch == 0:
1750                    continue
1751                if debug:
1752                    print("Epoch - PASSED")
1753                brief[1] += rest
1754                if rest >= nisab:
1755                    total = 0
1756                    for _ in range(epoch):
1757                        total += ZakatTracker.ZakatCut(float(rest) - float(total))
1758                    if total > 0:
1759                        if x not in plan:
1760                            plan[x] = {}
1761                        valid = True
1762                        brief[2] += total
1763                        plan[x][index] = {
1764                            'total': total,
1765                            'count': epoch,
1766                            'box_time': j,
1767                            'box_capital': _box[j]['capital'],
1768                            'box_rest': _box[j]['rest'],
1769                            'box_last': _box[j]['last'],
1770                            'box_total': _box[j]['total'],
1771                            'box_count': _box[j]['count'],
1772                            'box_log': _log[j]['desc'],
1773                            'exchange_rate': exchange['rate'],
1774                            'exchange_time': exchange['time'],
1775                            'exchange_desc': exchange['description'],
1776                        }
1777                else:
1778                    chunk = ZakatTracker.ZakatCut(float(rest))
1779                    if chunk > 0:
1780                        if x not in plan:
1781                            plan[x] = {}
1782                        if j not in plan[x].keys():
1783                            plan[x][index] = {}
1784                        below_nisab += rest
1785                        brief[2] += chunk
1786                        plan[x][index]['below_nisab'] = chunk
1787                        plan[x][index]['total'] = chunk
1788                        plan[x][index]['count'] = epoch
1789                        plan[x][index]['box_time'] = j
1790                        plan[x][index]['box_capital'] = _box[j]['capital']
1791                        plan[x][index]['box_rest'] = _box[j]['rest']
1792                        plan[x][index]['box_last'] = _box[j]['last']
1793                        plan[x][index]['box_total'] = _box[j]['total']
1794                        plan[x][index]['box_count'] = _box[j]['count']
1795                        plan[x][index]['box_log'] = _log[j]['desc']
1796                        plan[x][index]['exchange_rate'] = exchange['rate']
1797                        plan[x][index]['exchange_time'] = exchange['time']
1798                        plan[x][index]['exchange_desc'] = exchange['description']
1799        valid = valid or below_nisab >= nisab
1800        if debug:
1801            print(f"below_nisab({below_nisab}) >= nisab({nisab})")
1802        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:
1804    def build_payment_parts(self, scaled_demand: int, positive_only: bool = True) -> dict:
1805        """
1806        Build payment parts for the Zakat distribution.
1807
1808        Parameters:
1809        scaled_demand (int): The total demand for payment in local currency.
1810        positive_only (bool): If True, only consider accounts with positive balance. Default is True.
1811
1812        Returns:
1813        dict: A dictionary containing the payment parts for each account. The dictionary has the following structure:
1814        {
1815            'account': {
1816                'account_id': {'balance': float, 'rate': float, 'part': float},
1817                ...
1818            },
1819            'exceed': bool,
1820            'demand': int,
1821            'total': float,
1822        }
1823        """
1824        total = 0
1825        parts = {
1826            'account': {},
1827            'exceed': False,
1828            'demand': int(round(scaled_demand)),
1829        }
1830        for x, y in self.accounts().items():
1831            if positive_only and y <= 0:
1832                continue
1833            total += float(y)
1834            exchange = self.exchange(x)
1835            parts['account'][x] = {'balance': y, 'rate': exchange['rate'], 'part': 0}
1836        parts['total'] = total
1837        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:
1839    @staticmethod
1840    def check_payment_parts(parts: dict, debug: bool = False) -> int:
1841        """
1842        Checks the validity of payment parts.
1843
1844        Parameters:
1845        parts (dict): A dictionary containing payment parts information.
1846        debug (bool): Flag to enable debug mode.
1847
1848        Returns:
1849        int: Returns 0 if the payment parts are valid, otherwise returns the error code.
1850
1851        Error Codes:
1852        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
1853        2: 'balance', 'rate' or 'part' key is missing in parts['account'][x].
1854        3: 'part' value in parts['account'][x] is less than 0.
1855        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
1856        5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
1857        6: The sum of 'part' values in parts['account'] does not match with 'demand' value.
1858        """
1859        if debug:
1860            print('check_payment_parts', f'debug={debug}')
1861        for i in ['demand', 'account', 'total', 'exceed']:
1862            if i not in parts:
1863                return 1
1864        exceed = parts['exceed']
1865        for x in parts['account']:
1866            for j in ['balance', 'rate', 'part']:
1867                if j not in parts['account'][x]:
1868                    return 2
1869                if parts['account'][x]['part'] < 0:
1870                    return 3
1871                if not exceed and parts['account'][x]['balance'] <= 0:
1872                    return 4
1873        demand = parts['demand']
1874        z = 0
1875        for _, y in parts['account'].items():
1876            if not exceed and y['part'] > y['balance']:
1877                return 5
1878            z += ZakatTracker.exchange_calc(y['part'], y['rate'], 1)
1879        z = round(z, 2)
1880        demand = round(demand, 2)
1881        if debug:
1882            print('check_payment_parts', f'z = {z}, demand = {demand}')
1883            print('check_payment_parts', type(z), type(demand))
1884            print('check_payment_parts', z != demand)
1885            print('check_payment_parts', str(z) != str(demand))
1886        if z != demand and str(z) != str(demand):
1887            return 6
1888        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:
1890    def zakat(self, report: tuple, parts: Dict[str, Dict | bool | Any] = None, debug: bool = False) -> bool:
1891        """
1892        Perform Zakat calculation based on the given report and optional parts.
1893
1894        Parameters:
1895        report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan.
1896        parts (dict): A dictionary containing the payment parts for the zakat.
1897        debug (bool): A flag indicating whether to print debug information.
1898
1899        Returns:
1900        bool: True if the zakat calculation is successful, False otherwise.
1901        """
1902        if debug:
1903            print('zakat', f'debug={debug}')
1904        valid, _, plan = report
1905        if not valid:
1906            return valid
1907        parts_exist = parts is not None
1908        if parts_exist:
1909            if self.check_payment_parts(parts, debug=debug) != 0:
1910                return False
1911        if debug:
1912            print('######### zakat #######')
1913            print('parts_exist', parts_exist)
1914        no_lock = self.nolock()
1915        self.lock()
1916        report_time = self.time()
1917        self._vault['report'][report_time] = report
1918        self._step(Action.REPORT, ref=report_time)
1919        created = self.time()
1920        for x in plan:
1921            target_exchange = self.exchange(x)
1922            if debug:
1923                print(plan[x])
1924                print('-------------')
1925                print(self._vault['account'][x]['box'])
1926            ids = sorted(self._vault['account'][x]['box'].keys())
1927            if debug:
1928                print('plan[x]', plan[x])
1929            for i in plan[x].keys():
1930                j = ids[i]
1931                if debug:
1932                    print('i', i, 'j', j)
1933                self._step(Action.ZAKAT, account=x, ref=j, value=self._vault['account'][x]['box'][j]['last'],
1934                           key='last',
1935                           math_operation=MathOperation.EQUAL)
1936                self._vault['account'][x]['box'][j]['last'] = created
1937                amount = ZakatTracker.exchange_calc(float(plan[x][i]['total']), 1, float(target_exchange['rate']))
1938                self._vault['account'][x]['box'][j]['total'] += amount
1939                self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='total',
1940                           math_operation=MathOperation.ADDITION)
1941                self._vault['account'][x]['box'][j]['count'] += plan[x][i]['count']
1942                self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['count'], key='count',
1943                           math_operation=MathOperation.ADDITION)
1944                if not parts_exist:
1945                    try:
1946                        self._vault['account'][x]['box'][j]['rest'] -= amount
1947                    except TypeError:
1948                        self._vault['account'][x]['box'][j]['rest'] -= Decimal(amount)
1949                    # self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest',
1950                    #            math_operation=MathOperation.SUBTRACTION)
1951                    self._log(-float(amount), desc='zakat-زكاة', account=x, created=None, ref=j, debug=debug)
1952        if parts_exist:
1953            for account, part in parts['account'].items():
1954                if part['part'] == 0:
1955                    continue
1956                if debug:
1957                    print('zakat-part', account, part['rate'])
1958                target_exchange = self.exchange(account)
1959                amount = ZakatTracker.exchange_calc(part['part'], part['rate'], target_exchange['rate'])
1960                self.sub(
1961                    unscaled_value=self.unscale(int(amount)),
1962                    desc='zakat-part-دفعة-زكاة',
1963                    account=account,
1964                    debug=debug,
1965                )
1966        if no_lock:
1967            self.free(self.lock())
1968        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:
1970    def export_json(self, path: str = "data.json") -> bool:
1971        """
1972        Exports the current state of the ZakatTracker object to a JSON file.
1973
1974        Parameters:
1975        path (str): The path where the JSON file will be saved. Default is "data.json".
1976
1977        Returns:
1978        bool: True if the export is successful, False otherwise.
1979
1980        Raises:
1981        No specific exceptions are raised by this method.
1982        """
1983        with open(path, "w") as file:
1984            json.dump(self._vault, file, indent=4, cls=JSONEncoder)
1985            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:
1987    def save(self, path: str = None) -> bool:
1988        """
1989        Saves the ZakatTracker's current state to a camel file.
1990
1991        This method serializes the internal data (`_vault`).
1992
1993        Parameters:
1994        path (str, optional): File path for saving. Defaults to a predefined location.
1995
1996        Returns:
1997        bool: True if the save operation is successful, False otherwise.
1998        """
1999        if path is None:
2000            path = self.path()
2001        with open(f'{path}.tmp', 'w') as stream:
2002            # first save in tmp file
2003            stream.write(camel.dump(self._vault))
2004            # then move tmp file to original location
2005            shutil.move(f'{path}.tmp', path)
2006            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:
2008    def load(self, path: str = None) -> bool:
2009        """
2010        Load the current state of the ZakatTracker object from a camel file.
2011
2012        Parameters:
2013        path (str): The path where the camel file is located. If not provided, it will use the default path.
2014
2015        Returns:
2016        bool: True if the load operation is successful, False otherwise.
2017        """
2018        if path is None:
2019            path = self.path()
2020        if os.path.exists(path):
2021            with open(path, 'r') as stream:
2022                self._vault = camel.load(stream.read())
2023                return True
2024        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):
2026    def import_csv_cache_path(self):
2027        """
2028        Generates the cache file path for imported CSV data.
2029
2030        This function constructs the file path where cached data from CSV imports
2031        will be stored. The cache file is a camel file (.camel extension) appended
2032        to the base path of the object.
2033
2034        Returns:
2035        str: The full path to the import CSV cache file.
2036
2037        Example:
2038            >>> obj = ZakatTracker('/data/reports')
2039            >>> obj.import_csv_cache_path()
2040            '/data/reports.import_csv.camel'
2041        """
2042        path = str(self.path())
2043        ext = self.ext()
2044        ext_len = len(ext)
2045        if path.endswith(f'.{ext}'):
2046            path = path[:-ext_len - 1]
2047        _, filename = os.path.split(path + f'.import_csv.{ext}')
2048        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:
2050    def import_csv(self, path: str = 'file.csv', scale_decimal_places: int = 0, debug: bool = False) -> tuple:
2051        """
2052        The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system.
2053
2054        Parameters:
2055        path (str): The path to the CSV file. Default is 'file.csv'.
2056        scale_decimal_places (int): The number of decimal places to scale the value. Default is 0.
2057        debug (bool): A flag indicating whether to print debug information.
2058
2059        Returns:
2060        tuple: A tuple containing the number of transactions created, the number of transactions found in the cache,
2061                and a dictionary of bad transactions.
2062
2063        Notes:
2064            * Currency Pair Assumption: This function assumes that the exchange rates stored for each account
2065                                        are appropriate for the currency pairs involved in the conversions.
2066            * The exchange rate for each account is based on the last encountered transaction rate that is not equal
2067                to 1.0 or the previous rate for that account.
2068            * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent
2069              transactions of the same account within the whole imported and existing dataset when doing `check` and
2070              `zakat` operations.
2071
2072        Example Usage:
2073            The CSV file should have the following format, rate is optional per transaction:
2074            account, desc, value, date, rate
2075            For example:
2076            safe-45, "Some text", 34872, 1988-06-30 00:00:00, 1
2077        """
2078        if debug:
2079            print('import_csv', f'debug={debug}')
2080        cache: list[int] = []
2081        try:
2082            with open(self.import_csv_cache_path(), 'r') as stream:
2083                cache = camel.load(stream.read())
2084        except:
2085            pass
2086        date_formats = [
2087            "%Y-%m-%d %H:%M:%S",
2088            "%Y-%m-%dT%H:%M:%S",
2089            "%Y-%m-%dT%H%M%S",
2090            "%Y-%m-%d",
2091        ]
2092        created, found, bad = 0, 0, {}
2093        data: dict[int, list] = {}
2094        with open(path, newline='', encoding="utf-8") as f:
2095            i = 0
2096            for row in csv.reader(f, delimiter=','):
2097                i += 1
2098                hashed = hash(tuple(row))
2099                if hashed in cache:
2100                    found += 1
2101                    continue
2102                account = row[0]
2103                desc = row[1]
2104                value = float(row[2])
2105                rate = 1.0
2106                if row[4:5]:  # Empty list if index is out of range
2107                    rate = float(row[4])
2108                date: int = 0
2109                for time_format in date_formats:
2110                    try:
2111                        date = self.time(datetime.datetime.strptime(row[3], time_format))
2112                        break
2113                    except:
2114                        pass
2115                # TODO: not allowed for negative dates in the future after enhance time functions
2116                if date == 0:
2117                    bad[i] = row + ['invalid date']
2118                if value == 0:
2119                    bad[i] = row + ['invalid value']
2120                    continue
2121                if date not in data:
2122                    data[date] = []
2123                data[date].append((i, account, desc, value, date, rate, hashed))
2124
2125        if debug:
2126            print('import_csv', len(data))
2127
2128        if bad:
2129            return created, found, bad
2130
2131        for date, rows in sorted(data.items()):
2132            try:
2133                len_rows = len(rows)
2134                if len_rows == 1:
2135                    (_, account, desc, unscaled_value, date, rate, hashed) = rows[0]
2136                    value = self.unscale(
2137                        unscaled_value,
2138                        decimal_places=scale_decimal_places,
2139                    ) if scale_decimal_places > 0 else unscaled_value
2140                    if rate > 0:
2141                        self.exchange(account=account, created=date, rate=rate)
2142                    if value > 0:
2143                        self.track(unscaled_value=value, desc=desc, account=account, logging=True, created=date)
2144                    elif value < 0:
2145                        self.sub(unscaled_value=-value, desc=desc, account=account, created=date)
2146                    created += 1
2147                    cache.append(hashed)
2148                    continue
2149                if debug:
2150                    print('-- Duplicated time detected', date, 'len', len_rows)
2151                    print(rows)
2152                    print('---------------------------------')
2153                # If records are found at the same time with different accounts in the same amount
2154                # (one positive and the other negative), this indicates it is a transfer.
2155                if len_rows != 2:
2156                    raise Exception(f'more than two transactions({len_rows}) at the same time')
2157                (i, account1, desc1, unscaled_value1, date1, rate1, _) = rows[0]
2158                (j, account2, desc2, unscaled_value2, date2, rate2, _) = rows[1]
2159                if account1 == account2 or desc1 != desc2 or abs(unscaled_value1) != abs(
2160                        unscaled_value2) or date1 != date2:
2161                    raise Exception('invalid transfer')
2162                if rate1 > 0:
2163                    self.exchange(account1, created=date1, rate=rate1)
2164                if rate2 > 0:
2165                    self.exchange(account2, created=date2, rate=rate2)
2166                value1 = self.unscale(
2167                    unscaled_value1,
2168                    decimal_places=scale_decimal_places,
2169                ) if scale_decimal_places > 0 else unscaled_value1
2170                value2 = self.unscale(
2171                    unscaled_value2,
2172                    decimal_places=scale_decimal_places,
2173                ) if scale_decimal_places > 0 else unscaled_value2
2174                values = {
2175                    value1: account1,
2176                    value2: account2,
2177                }
2178                self.transfer(
2179                    unscaled_amount=abs(value1),
2180                    from_account=values[min(values.keys())],
2181                    to_account=values[max(values.keys())],
2182                    desc=desc1,
2183                    created=date1,
2184                )
2185            except Exception as e:
2186                for (i, account, desc, value, date, rate, _) in rows:
2187                    bad[i] = (account, desc, value, date, rate, e)
2188                break
2189        with open(self.import_csv_cache_path(), 'w') as stream:
2190            stream.write(camel.dump(cache))
2191        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:
2197    @staticmethod
2198    def human_readable_size(size: float, decimal_places: int = 2) -> str:
2199        """
2200        Converts a size in bytes to a human-readable format (e.g., KB, MB, GB).
2201
2202        This function iterates through progressively larger units of information
2203        (B, KB, MB, GB, etc.) and divides the input size until it fits within a
2204        range that can be expressed with a reasonable number before the unit.
2205
2206        Parameters:
2207        size (float): The size in bytes to convert.
2208        decimal_places (int, optional): The number of decimal places to display
2209            in the result. Defaults to 2.
2210
2211        Returns:
2212        str: A string representation of the size in a human-readable format,
2213            rounded to the specified number of decimal places. For example:
2214                - "1.50 KB" (1536 bytes)
2215                - "23.00 MB" (24117248 bytes)
2216                - "1.23 GB" (1325899906 bytes)
2217        """
2218        if type(size) not in (float, int):
2219            raise TypeError("size must be a float or integer")
2220        if type(decimal_places) != int:
2221            raise TypeError("decimal_places must be an integer")
2222        for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']:
2223            if size < 1024.0:
2224                break
2225            size /= 1024.0
2226        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:
2228    @staticmethod
2229    def get_dict_size(obj: dict, seen: set = None) -> float:
2230        """
2231        Recursively calculates the approximate memory size of a dictionary and its contents in bytes.
2232
2233        This function traverses the dictionary structure, accounting for the size of keys, values,
2234        and any nested objects. It handles various data types commonly found in dictionaries
2235        (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case
2236        of circular references.
2237
2238        Parameters:
2239        obj (dict): The dictionary whose size is to be calculated.
2240        seen (set, optional): A set used internally to track visited objects
2241                             and avoid circular references. Defaults to None.
2242
2243        Returns:
2244            float: An approximate size of the dictionary and its contents in bytes.
2245
2246        Note:
2247        - This function is a method of the `ZakatTracker` class and is likely used to
2248          estimate the memory footprint of data structures relevant to Zakat calculations.
2249        - The size calculation is approximate as it relies on `sys.getsizeof()`, which might
2250          not account for all memory overhead depending on the Python implementation.
2251        - Circular references are handled to prevent infinite recursion.
2252        - Basic numeric types (int, float, complex) are assumed to have fixed sizes.
2253        - String sizes are estimated based on character length and encoding.
2254        """
2255        size = 0
2256        if seen is None:
2257            seen = set()
2258
2259        obj_id = id(obj)
2260        if obj_id in seen:
2261            return 0
2262
2263        seen.add(obj_id)
2264        size += sys.getsizeof(obj)
2265
2266        if isinstance(obj, dict):
2267            for k, v in obj.items():
2268                size += ZakatTracker.get_dict_size(k, seen)
2269                size += ZakatTracker.get_dict_size(v, seen)
2270        elif isinstance(obj, (list, tuple, set, frozenset)):
2271            for item in obj:
2272                size += ZakatTracker.get_dict_size(item, seen)
2273        elif isinstance(obj, (int, float, complex)):  # Handle numbers
2274            pass  # Basic numbers have a fixed size, so nothing to add here
2275        elif isinstance(obj, str):  # Handle strings
2276            size += len(obj) * sys.getsizeof(str().encode())  # Size per character in bytes
2277        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:
2279    @staticmethod
2280    def duration_from_nanoseconds(ns: int,
2281                                  show_zeros_in_spoken_time: bool = False,
2282                                  spoken_time_separator=',',
2283                                  millennia: str = 'Millennia',
2284                                  century: str = 'Century',
2285                                  years: str = 'Years',
2286                                  days: str = 'Days',
2287                                  hours: str = 'Hours',
2288                                  minutes: str = 'Minutes',
2289                                  seconds: str = 'Seconds',
2290                                  milli_seconds: str = 'MilliSeconds',
2291                                  micro_seconds: str = 'MicroSeconds',
2292                                  nano_seconds: str = 'NanoSeconds',
2293                                  ) -> tuple:
2294        """
2295        REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
2296        Convert NanoSeconds to Human Readable Time Format.
2297        A NanoSeconds is a unit of time in the International System of Units (SI) equal
2298        to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
2299        Its symbol is μs, sometimes simplified to us when Unicode is not available.
2300        A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
2301
2302        INPUT : ms (AKA: MilliSeconds)
2303        OUTPUT: tuple(string time_lapsed, string spoken_time) like format.
2304        OUTPUT Variables: time_lapsed, spoken_time
2305
2306        Example  Input: duration_from_nanoseconds(ns)
2307        **"Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds"**
2308        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')
2309        duration_from_nanoseconds(1234567890123456789012)
2310        """
2311        us, ns = divmod(ns, 1000)
2312        ms, us = divmod(us, 1000)
2313        s, ms = divmod(ms, 1000)
2314        m, s = divmod(s, 60)
2315        h, m = divmod(m, 60)
2316        d, h = divmod(h, 24)
2317        y, d = divmod(d, 365)
2318        c, y = divmod(y, 100)
2319        n, c = divmod(c, 10)
2320        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}"
2321        spoken_time_part = []
2322        if n > 0 or show_zeros_in_spoken_time:
2323            spoken_time_part.append(f"{n: 3d} {millennia}")
2324        if c > 0 or show_zeros_in_spoken_time:
2325            spoken_time_part.append(f"{c: 4d} {century}")
2326        if y > 0 or show_zeros_in_spoken_time:
2327            spoken_time_part.append(f"{y: 3d} {years}")
2328        if d > 0 or show_zeros_in_spoken_time:
2329            spoken_time_part.append(f"{d: 4d} {days}")
2330        if h > 0 or show_zeros_in_spoken_time:
2331            spoken_time_part.append(f"{h: 2d} {hours}")
2332        if m > 0 or show_zeros_in_spoken_time:
2333            spoken_time_part.append(f"{m: 2d} {minutes}")
2334        if s > 0 or show_zeros_in_spoken_time:
2335            spoken_time_part.append(f"{s: 2d} {seconds}")
2336        if ms > 0 or show_zeros_in_spoken_time:
2337            spoken_time_part.append(f"{ms: 3d} {milli_seconds}")
2338        if us > 0 or show_zeros_in_spoken_time:
2339            spoken_time_part.append(f"{us: 3d} {micro_seconds}")
2340        if ns > 0 or show_zeros_in_spoken_time:
2341            spoken_time_part.append(f"{ns: 3d} {nano_seconds}")
2342        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:
2344    @staticmethod
2345    def day_to_time(day: int, month: int = 6, year: int = 2024) -> int:  # افتراض أن الشهر هو يونيو والسنة 2024
2346        """
2347        Convert a specific day, month, and year into a timestamp.
2348
2349        Parameters:
2350        day (int): The day of the month.
2351        month (int): The month of the year. Default is 6 (June).
2352        year (int): The year. Default is 2024.
2353
2354        Returns:
2355        int: The timestamp representing the given day, month, and year.
2356
2357        Note:
2358        This method assumes the default month and year if not provided.
2359        """
2360        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:
2362    @staticmethod
2363    def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
2364        """
2365        Generate a random date between two given dates.
2366
2367        Parameters:
2368        start_date (datetime.datetime): The start date from which to generate a random date.
2369        end_date (datetime.datetime): The end date until which to generate a random date.
2370
2371        Returns:
2372        datetime.datetime: A random date between the start_date and end_date.
2373        """
2374        time_between_dates = end_date - start_date
2375        days_between_dates = time_between_dates.days
2376        random_number_of_days = random.randrange(days_between_dates)
2377        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:
2379    @staticmethod
2380    def generate_random_csv_file(path: str = "data.csv", count: int = 1000, with_rate: bool = False,
2381                                 debug: bool = False) -> int:
2382        """
2383        Generate a random CSV file with specified parameters.
2384
2385        Parameters:
2386        path (str): The path where the CSV file will be saved. Default is "data.csv".
2387        count (int): The number of rows to generate in the CSV file. Default is 1000.
2388        with_rate (bool): If True, a random rate between 1.2% and 12% is added. Default is False.
2389        debug (bool): A flag indicating whether to print debug information.
2390
2391        Returns:
2392        None. The function generates a CSV file at the specified path with the given count of rows.
2393        Each row contains a randomly generated account, description, value, and date.
2394        The value is randomly generated between 1000 and 100000,
2395        and the date is randomly generated between 1950-01-01 and 2023-12-31.
2396        If the row number is not divisible by 13, the value is multiplied by -1.
2397        """
2398        if debug:
2399            print('generate_random_csv_file', f'debug={debug}')
2400        i = 0
2401        with open(path, "w", newline="") as csvfile:
2402            writer = csv.writer(csvfile)
2403            for i in range(count):
2404                account = f"acc-{random.randint(1, 1000)}"
2405                desc = f"Some text {random.randint(1, 1000)}"
2406                value = random.randint(1000, 100000)
2407                date = ZakatTracker.generate_random_date(datetime.datetime(1000, 1, 1),
2408                                                         datetime.datetime(2023, 12, 31)).strftime("%Y-%m-%d %H:%M:%S")
2409                if not i % 13 == 0:
2410                    value *= -1
2411                row = [account, desc, value, date]
2412                if with_rate:
2413                    rate = random.randint(1, 100) * 0.12
2414                    if debug:
2415                        print('before-append', row)
2416                    row.append(rate)
2417                    if debug:
2418                        print('after-append', row)
2419                writer.writerow(row)
2420                i = i + 1
2421        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):
2423    @staticmethod
2424    def create_random_list(max_sum, min_value=0, max_value=10):
2425        """
2426        Creates a list of random integers whose sum does not exceed the specified maximum.
2427
2428        Args:
2429            max_sum: The maximum allowed sum of the list elements.
2430            min_value: The minimum possible value for an element (inclusive).
2431            max_value: The maximum possible value for an element (inclusive).
2432
2433        Returns:
2434            A list of random integers.
2435        """
2436        result = []
2437        current_sum = 0
2438
2439        while current_sum < max_sum:
2440            # Calculate the remaining space for the next element
2441            remaining_sum = max_sum - current_sum
2442            # Determine the maximum possible value for the next element
2443            next_max_value = min(remaining_sum, max_value)
2444            # Generate a random element within the allowed range
2445            next_element = random.randint(min_value, next_max_value)
2446            result.append(next_element)
2447            current_sum += next_element
2448
2449        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:
2680    def test(self, debug: bool = False) -> bool:
2681        if debug:
2682            print('test', f'debug={debug}')
2683        try:
2684
2685            self._test_core(True, debug)
2686            self._test_core(False, debug)
2687
2688            assert self._history()
2689
2690            # Not allowed for duplicate transactions in the same account and time
2691
2692            created = ZakatTracker.time()
2693            self.track(100, 'test-1', 'same', True, created)
2694            failed = False
2695            try:
2696                self.track(50, 'test-1', 'same', True, created)
2697            except:
2698                failed = True
2699            assert failed is True
2700
2701            self.reset()
2702
2703            # Same account transfer
2704            for x in [1, 'a', True, 1.8, None]:
2705                failed = False
2706                try:
2707                    self.transfer(1, x, x, 'same-account', debug=debug)
2708                except:
2709                    failed = True
2710                assert failed is True
2711
2712            # Always preserve box age during transfer
2713
2714            series: list[tuple] = [
2715                (30, 4),
2716                (60, 3),
2717                (90, 2),
2718            ]
2719            case = {
2720                3000: {
2721                    'series': series,
2722                    'rest': 15000,
2723                },
2724                6000: {
2725                    'series': series,
2726                    'rest': 12000,
2727                },
2728                9000: {
2729                    'series': series,
2730                    'rest': 9000,
2731                },
2732                18000: {
2733                    'series': series,
2734                    'rest': 0,
2735                },
2736                27000: {
2737                    'series': series,
2738                    'rest': -9000,
2739                },
2740                36000: {
2741                    'series': series,
2742                    'rest': -18000,
2743                },
2744            }
2745
2746            selected_time = ZakatTracker.time() - ZakatTracker.TimeCycle()
2747
2748            for total in case:
2749                if debug:
2750                    print('--------------------------------------------------------')
2751                    print(f'case[{total}]', case[total])
2752                for x in case[total]['series']:
2753                    self.track(
2754                        unscaled_value=x[0],
2755                        desc=f"test-{x} ages",
2756                        account='ages',
2757                        logging=True,
2758                        created=selected_time * x[1],
2759                    )
2760
2761                unscaled_total = self.unscale(total)
2762                if debug:
2763                    print('unscaled_total', unscaled_total)
2764                refs = self.transfer(
2765                    unscaled_amount=unscaled_total,
2766                    from_account='ages',
2767                    to_account='future',
2768                    desc='Zakat Movement',
2769                    debug=debug,
2770                )
2771
2772                if debug:
2773                    print('refs', refs)
2774
2775                ages_cache_balance = self.balance('ages')
2776                ages_fresh_balance = self.balance('ages', False)
2777                rest = case[total]['rest']
2778                if debug:
2779                    print('source', ages_cache_balance, ages_fresh_balance, rest)
2780                assert ages_cache_balance == rest
2781                assert ages_fresh_balance == rest
2782
2783                future_cache_balance = self.balance('future')
2784                future_fresh_balance = self.balance('future', False)
2785                if debug:
2786                    print('target', future_cache_balance, future_fresh_balance, total)
2787                    print('refs', refs)
2788                assert future_cache_balance == total
2789                assert future_fresh_balance == total
2790
2791                # TODO: check boxes times for `ages` should equal box times in `future`
2792                for ref in self._vault['account']['ages']['box']:
2793                    ages_capital = self._vault['account']['ages']['box'][ref]['capital']
2794                    ages_rest = self._vault['account']['ages']['box'][ref]['rest']
2795                    future_capital = 0
2796                    if ref in self._vault['account']['future']['box']:
2797                        future_capital = self._vault['account']['future']['box'][ref]['capital']
2798                    future_rest = 0
2799                    if ref in self._vault['account']['future']['box']:
2800                        future_rest = self._vault['account']['future']['box'][ref]['rest']
2801                    if ages_capital != 0 and future_capital != 0 and future_rest != 0:
2802                        if debug:
2803                            print('================================================================')
2804                            print('ages', ages_capital, ages_rest)
2805                            print('future', future_capital, future_rest)
2806                        if ages_rest == 0:
2807                            assert ages_capital == future_capital
2808                        elif ages_rest < 0:
2809                            assert -ages_capital == future_capital
2810                        elif ages_rest > 0:
2811                            assert ages_capital == ages_rest + future_capital
2812                self.reset()
2813                assert len(self._vault['history']) == 0
2814
2815            assert self._history()
2816            assert self._history(False) is False
2817            assert self._history() is False
2818            assert self._history(True)
2819            assert self._history()
2820            if debug:
2821                print('####################################################################')
2822
2823            transaction = [
2824                (
2825                    20, 'wallet', 1, -2000, -2000, -2000, 1, 1,
2826                    2000, 2000, 2000, 1, 1,
2827                ),
2828                (
2829                    750, 'wallet', 'safe', -77000, -77000, -77000, 2, 2,
2830                    75000, 75000, 75000, 1, 1,
2831                ),
2832                (
2833                    600, 'safe', 'bank', 15000, 15000, 15000, 1, 2,
2834                    60000, 60000, 60000, 1, 1,
2835                ),
2836            ]
2837            for z in transaction:
2838                self.lock()
2839                x = z[1]
2840                y = z[2]
2841                self.transfer(
2842                    unscaled_amount=z[0],
2843                    from_account=x,
2844                    to_account=y,
2845                    desc='test-transfer',
2846                    debug=debug,
2847                )
2848                zz = self.balance(x)
2849                if debug:
2850                    print(zz, z)
2851                assert zz == z[3]
2852                xx = self.accounts()[x]
2853                assert xx == z[3]
2854                assert self.balance(x, False) == z[4]
2855                assert xx == z[4]
2856
2857                s = 0
2858                log = self._vault['account'][x]['log']
2859                for i in log:
2860                    s += log[i]['value']
2861                if debug:
2862                    print('s', s, 'z[5]', z[5])
2863                assert s == z[5]
2864
2865                assert self.box_size(x) == z[6]
2866                assert self.log_size(x) == z[7]
2867
2868                yy = self.accounts()[y]
2869                assert self.balance(y) == z[8]
2870                assert yy == z[8]
2871                assert self.balance(y, False) == z[9]
2872                assert yy == z[9]
2873
2874                s = 0
2875                log = self._vault['account'][y]['log']
2876                for i in log:
2877                    s += log[i]['value']
2878                assert s == z[10]
2879
2880                assert self.box_size(y) == z[11]
2881                assert self.log_size(y) == z[12]
2882                assert self.free(self.lock())
2883
2884            if debug:
2885                pp().pprint(self.check(2.17))
2886
2887            assert not self.nolock()
2888            history_count = len(self._vault['history'])
2889            if debug:
2890                print('history-count', history_count)
2891            assert history_count == 4
2892            assert not self.free(ZakatTracker.time())
2893            assert self.free(self.lock())
2894            assert self.nolock()
2895            assert len(self._vault['history']) == 3
2896
2897            # storage
2898
2899            _path = self.path(f'./zakat_test_db/test.{self.ext()}')
2900            if os.path.exists(_path):
2901                os.remove(_path)
2902            self.save()
2903            assert os.path.getsize(_path) > 0
2904            self.reset()
2905            assert self.recall(False, debug) is False
2906            self.load()
2907            assert self._vault['account'] is not None
2908
2909            # recall
2910
2911            assert self.nolock()
2912            assert len(self._vault['history']) == 3
2913            assert self.recall(False, debug) is True
2914            assert len(self._vault['history']) == 2
2915            assert self.recall(False, debug) is True
2916            assert len(self._vault['history']) == 1
2917            assert self.recall(False, debug) is True
2918            assert len(self._vault['history']) == 0
2919            assert self.recall(False, debug) is False
2920            assert len(self._vault['history']) == 0
2921
2922            # exchange
2923
2924            self.exchange("cash", 25, 3.75, "2024-06-25")
2925            self.exchange("cash", 22, 3.73, "2024-06-22")
2926            self.exchange("cash", 15, 3.69, "2024-06-15")
2927            self.exchange("cash", 10, 3.66)
2928
2929            for i in range(1, 30):
2930                exchange = self.exchange("cash", i)
2931                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2932                if debug:
2933                    print(i, rate, description, created)
2934                assert created
2935                if i < 10:
2936                    assert rate == 1
2937                    assert description is None
2938                elif i == 10:
2939                    assert rate == 3.66
2940                    assert description is None
2941                elif i < 15:
2942                    assert rate == 3.66
2943                    assert description is None
2944                elif i == 15:
2945                    assert rate == 3.69
2946                    assert description is not None
2947                elif i < 22:
2948                    assert rate == 3.69
2949                    assert description is not None
2950                elif i == 22:
2951                    assert rate == 3.73
2952                    assert description is not None
2953                elif i >= 25:
2954                    assert rate == 3.75
2955                    assert description is not None
2956                exchange = self.exchange("bank", i)
2957                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2958                if debug:
2959                    print(i, rate, description, created)
2960                assert created
2961                assert rate == 1
2962                assert description is None
2963
2964            assert len(self._vault['exchange']) > 0
2965            assert len(self.exchanges()) > 0
2966            self._vault['exchange'].clear()
2967            assert len(self._vault['exchange']) == 0
2968            assert len(self.exchanges()) == 0
2969
2970            # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية
2971            self.exchange("cash", ZakatTracker.day_to_time(25), 3.75, "2024-06-25")
2972            self.exchange("cash", ZakatTracker.day_to_time(22), 3.73, "2024-06-22")
2973            self.exchange("cash", ZakatTracker.day_to_time(15), 3.69, "2024-06-15")
2974            self.exchange("cash", ZakatTracker.day_to_time(10), 3.66)
2975
2976            for i in [x * 0.12 for x in range(-15, 21)]:
2977                if i <= 0:
2978                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) == 0
2979                else:
2980                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) > 0
2981
2982            # اختبار النتائج باستخدام التواريخ بالنانو ثانية
2983            for i in range(1, 31):
2984                timestamp_ns = ZakatTracker.day_to_time(i)
2985                exchange = self.exchange("cash", timestamp_ns)
2986                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2987                if debug:
2988                    print(i, rate, description, created)
2989                assert created
2990                if i < 10:
2991                    assert rate == 1
2992                    assert description is None
2993                elif i == 10:
2994                    assert rate == 3.66
2995                    assert description is None
2996                elif i < 15:
2997                    assert rate == 3.66
2998                    assert description is None
2999                elif i == 15:
3000                    assert rate == 3.69
3001                    assert description is not None
3002                elif i < 22:
3003                    assert rate == 3.69
3004                    assert description is not None
3005                elif i == 22:
3006                    assert rate == 3.73
3007                    assert description is not None
3008                elif i >= 25:
3009                    assert rate == 3.75
3010                    assert description is not None
3011                exchange = self.exchange("bank", i)
3012                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
3013                if debug:
3014                    print(i, rate, description, created)
3015                assert created
3016                assert rate == 1
3017                assert description is None
3018
3019            # csv
3020
3021            csv_count = 1000
3022
3023            for with_rate, path in {
3024                False: 'test-import_csv-no-exchange',
3025                True: 'test-import_csv-with-exchange',
3026            }.items():
3027
3028                if debug:
3029                    print('test_import_csv', with_rate, path)
3030
3031                csv_path = path + '.csv'
3032                if os.path.exists(csv_path):
3033                    os.remove(csv_path)
3034                c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug)
3035                if debug:
3036                    print('generate_random_csv_file', c)
3037                assert c == csv_count
3038                assert os.path.getsize(csv_path) > 0
3039                cache_path = self.import_csv_cache_path()
3040                if os.path.exists(cache_path):
3041                    os.remove(cache_path)
3042                self.reset()
3043                (created, found, bad) = self.import_csv(csv_path, debug)
3044                bad_count = len(bad)
3045                assert bad_count > 0
3046                if debug:
3047                    print(f"csv-imported: ({created}, {found}, {bad_count}) = count({csv_count})")
3048                    print('bad', bad)
3049                tmp_size = os.path.getsize(cache_path)
3050                assert tmp_size > 0
3051                # TODO: assert created + found + bad_count == csv_count
3052                # TODO: assert created == csv_count
3053                # TODO: assert bad_count == 0
3054                (created_2, found_2, bad_2) = self.import_csv(csv_path)
3055                bad_2_count = len(bad_2)
3056                if debug:
3057                    print(f"csv-imported: ({created_2}, {found_2}, {bad_2_count})")
3058                    print('bad', bad)
3059                assert bad_2_count > 0
3060                # TODO: assert tmp_size == os.path.getsize(cache_path)
3061                # TODO: assert created_2 + found_2 + bad_2_count == csv_count
3062                # TODO: assert created == found_2
3063                # TODO: assert bad_count == bad_2_count
3064                # TODO: assert found_2 == csv_count
3065                # TODO: assert bad_2_count == 0
3066                # TODO: assert created_2 == 0
3067
3068                # payment parts
3069
3070                positive_parts = self.build_payment_parts(100, positive_only=True)
3071                assert self.check_payment_parts(positive_parts) != 0
3072                assert self.check_payment_parts(positive_parts) != 0
3073                all_parts = self.build_payment_parts(300, positive_only=False)
3074                assert self.check_payment_parts(all_parts) != 0
3075                assert self.check_payment_parts(all_parts) != 0
3076                if debug:
3077                    pp().pprint(positive_parts)
3078                    pp().pprint(all_parts)
3079                # dynamic discount
3080                suite = []
3081                count = 3
3082                for exceed in [False, True]:
3083                    case = []
3084                    for parts in [positive_parts, all_parts]:
3085                        part = parts.copy()
3086                        demand = part['demand']
3087                        if debug:
3088                            print(demand, part['total'])
3089                        i = 0
3090                        z = demand / count
3091                        cp = {
3092                            'account': {},
3093                            'demand': demand,
3094                            'exceed': exceed,
3095                            'total': part['total'],
3096                        }
3097                        j = ''
3098                        for x, y in part['account'].items():
3099                            x_exchange = self.exchange(x)
3100                            zz = self.exchange_calc(z, 1, x_exchange['rate'])
3101                            if exceed and zz <= demand:
3102                                i += 1
3103                                y['part'] = zz
3104                                if debug:
3105                                    print(exceed, y)
3106                                cp['account'][x] = y
3107                                case.append(y)
3108                            elif not exceed and y['balance'] >= zz:
3109                                i += 1
3110                                y['part'] = zz
3111                                if debug:
3112                                    print(exceed, y)
3113                                cp['account'][x] = y
3114                                case.append(y)
3115                            j = x
3116                            if i >= count:
3117                                break
3118                        if len(cp['account'][j]) > 0:
3119                            suite.append(cp)
3120                if debug:
3121                    print('suite', len(suite))
3122                # vault = self._vault.copy()
3123                for case in suite:
3124                    # self._vault = vault.copy()
3125                    if debug:
3126                        print('case', case)
3127                    result = self.check_payment_parts(case)
3128                    if debug:
3129                        print('check_payment_parts', result, f'exceed: {exceed}')
3130                    assert result == 0
3131
3132                    report = self.check(2.17, None, debug)
3133                    (valid, brief, plan) = report
3134                    if debug:
3135                        print('valid', valid)
3136                    zakat_result = self.zakat(report, parts=case, debug=debug)
3137                    if debug:
3138                        print('zakat-result', zakat_result)
3139                    assert valid == zakat_result
3140
3141            assert self.save(path + f'.{self.ext()}')
3142            assert self.export_json(path + '.json')
3143
3144            assert self.export_json("1000-transactions-test.json")
3145            assert self.save(f"1000-transactions-test.{self.ext()}")
3146
3147            self.reset()
3148
3149            # test transfer between accounts with different exchange rate
3150
3151            a_SAR = "Bank (SAR)"
3152            b_USD = "Bank (USD)"
3153            c_SAR = "Safe (SAR)"
3154            # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer
3155            for case in [
3156                (0, a_SAR, "SAR Gift", 1000, 100000),
3157                (1, a_SAR, 1),
3158                (0, b_USD, "USD Gift", 500, 50000),
3159                (1, b_USD, 1),
3160                (2, b_USD, 3.75),
3161                (1, b_USD, 3.75),
3162                (3, 100, b_USD, a_SAR, "100 USD -> SAR", 40000, 137500),
3163                (0, c_SAR, "Salary", 750, 75000),
3164                (3, 375, c_SAR, b_USD, "375 SAR -> USD", 37500, 50000),
3165                (3, 3.75, a_SAR, b_USD, "3.75 SAR -> USD", 137125, 50100),
3166            ]:
3167                if debug:
3168                    print('case', case)
3169                match (case[0]):
3170                    case 0:  # track
3171                        _, account, desc, x, balance = case
3172                        self.track(unscaled_value=x, desc=desc, account=account, debug=debug)
3173
3174                        cached_value = self.balance(account, cached=True)
3175                        fresh_value = self.balance(account, cached=False)
3176                        if debug:
3177                            print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value)
3178                        assert cached_value == balance
3179                        assert fresh_value == balance
3180                    case 1:  # check-exchange
3181                        _, account, expected_rate = case
3182                        t_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
3183                        if debug:
3184                            print('t-exchange', t_exchange)
3185                        assert t_exchange['rate'] == expected_rate
3186                    case 2:  # do-exchange
3187                        _, account, rate = case
3188                        self.exchange(account, rate=rate, debug=debug)
3189                        b_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
3190                        if debug:
3191                            print('b-exchange', b_exchange)
3192                        assert b_exchange['rate'] == rate
3193                    case 3:  # transfer
3194                        _, x, a, b, desc, a_balance, b_balance = case
3195                        self.transfer(x, a, b, desc, debug=debug)
3196
3197                        cached_value = self.balance(a, cached=True)
3198                        fresh_value = self.balance(a, cached=False)
3199                        if debug:
3200                            print(
3201                                'account', a,
3202                                'cached_value', cached_value,
3203                                'fresh_value', fresh_value,
3204                                'a_balance', a_balance,
3205                            )
3206                        assert cached_value == a_balance
3207                        assert fresh_value == a_balance
3208
3209                        cached_value = self.balance(b, cached=True)
3210                        fresh_value = self.balance(b, cached=False)
3211                        if debug:
3212                            print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value)
3213                        assert cached_value == b_balance
3214                        assert fresh_value == b_balance
3215
3216            # Transfer all in many chunks randomly from B to A
3217            a_SAR_balance = 137125
3218            b_USD_balance = 50100
3219            b_USD_exchange = self.exchange(b_USD)
3220            amounts = ZakatTracker.create_random_list(b_USD_balance, max_value=1000)
3221            if debug:
3222                print('amounts', amounts)
3223            i = 0
3224            for x in amounts:
3225                if debug:
3226                    print(f'{i} - transfer-with-exchange({x})')
3227                self.transfer(
3228                    unscaled_amount=self.unscale(x),
3229                    from_account=b_USD,
3230                    to_account=a_SAR,
3231                    desc=f"{x} USD -> SAR",
3232                    debug=debug,
3233                )
3234
3235                b_USD_balance -= x
3236                cached_value = self.balance(b_USD, cached=True)
3237                fresh_value = self.balance(b_USD, cached=False)
3238                if debug:
3239                    print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
3240                          b_USD_balance)
3241                assert cached_value == b_USD_balance
3242                assert fresh_value == b_USD_balance
3243
3244                a_SAR_balance += int(x * b_USD_exchange['rate'])
3245                cached_value = self.balance(a_SAR, cached=True)
3246                fresh_value = self.balance(a_SAR, cached=False)
3247                if debug:
3248                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
3249                          a_SAR_balance, 'rate', b_USD_exchange['rate'])
3250                assert cached_value == a_SAR_balance
3251                assert fresh_value == a_SAR_balance
3252                i += 1
3253
3254            # Transfer all in many chunks randomly from C to A
3255            c_SAR_balance = 37500
3256            amounts = ZakatTracker.create_random_list(c_SAR_balance, max_value=1000)
3257            if debug:
3258                print('amounts', amounts)
3259            i = 0
3260            for x in amounts:
3261                if debug:
3262                    print(f'{i} - transfer-with-exchange({x})')
3263                self.transfer(
3264                    unscaled_amount=self.unscale(x),
3265                    from_account=c_SAR,
3266                    to_account=a_SAR,
3267                    desc=f"{x} SAR -> a_SAR",
3268                    debug=debug,
3269                )
3270
3271                c_SAR_balance -= x
3272                cached_value = self.balance(c_SAR, cached=True)
3273                fresh_value = self.balance(c_SAR, cached=False)
3274                if debug:
3275                    print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
3276                          c_SAR_balance)
3277                assert cached_value == c_SAR_balance
3278                assert fresh_value == c_SAR_balance
3279
3280                a_SAR_balance += x
3281                cached_value = self.balance(a_SAR, cached=True)
3282                fresh_value = self.balance(a_SAR, cached=False)
3283                if debug:
3284                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
3285                          a_SAR_balance)
3286                assert cached_value == a_SAR_balance
3287                assert fresh_value == a_SAR_balance
3288                i += 1
3289
3290            assert self.export_json("accounts-transfer-with-exchange-rates.json")
3291            assert self.save(f"accounts-transfer-with-exchange-rates.{self.ext()}")
3292
3293            # check & zakat with exchange rates for many cycles
3294
3295            for rate, values in {
3296                1: {
3297                    'in': [1000, 2000, 10000],
3298                    'exchanged': [100000, 200000, 1000000],
3299                    'out': [2500, 5000, 73140],
3300                },
3301                3.75: {
3302                    'in': [200, 1000, 5000],
3303                    'exchanged': [75000, 375000, 1875000],
3304                    'out': [1875, 9375, 137138],
3305                },
3306            }.items():
3307                a, b, c = values['in']
3308                m, n, o = values['exchanged']
3309                x, y, z = values['out']
3310                if debug:
3311                    print('rate', rate, 'values', values)
3312                for case in [
3313                    (a, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
3314                        {'safe': {0: {'below_nisab': x}}},
3315                    ], False, m),
3316                    (b, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
3317                        {'safe': {0: {'count': 1, 'total': y}}},
3318                    ], True, n),
3319                    (c, 'cave', ZakatTracker.time() - (ZakatTracker.TimeCycle() * 3), [
3320                        {'cave': {0: {'count': 3, 'total': z}}},
3321                    ], True, o),
3322                ]:
3323                    if debug:
3324                        print(f"############# check(rate: {rate}) #############")
3325                        print('case', case)
3326                    self.reset()
3327                    self.exchange(account=case[1], created=case[2], rate=rate)
3328                    self.track(
3329                        unscaled_value=case[0],
3330                        desc='test-check',
3331                        account=case[1],
3332                        logging=True,
3333                        created=case[2],
3334                    )
3335                    assert self.snapshot()
3336
3337                    # assert self.nolock()
3338                    # history_size = len(self._vault['history'])
3339                    # print('history_size', history_size)
3340                    # assert history_size == 2
3341                    assert self.lock()
3342                    assert not self.nolock()
3343                    report = self.check(2.17, None, debug)
3344                    (valid, brief, plan) = report
3345                    if debug:
3346                        print('brief', brief)
3347                    assert valid == case[4]
3348                    assert case[5] == brief[0]
3349                    assert case[5] == brief[1]
3350
3351                    if debug:
3352                        pp().pprint(plan)
3353
3354                    for x in plan:
3355                        assert case[1] == x
3356                        if 'total' in case[3][0][x][0].keys():
3357                            assert case[3][0][x][0]['total'] == int(brief[2])
3358                            assert int(plan[x][0]['total']) == case[3][0][x][0]['total']
3359                            assert int(plan[x][0]['count']) == case[3][0][x][0]['count']
3360                        else:
3361                            assert plan[x][0]['below_nisab'] == case[3][0][x][0]['below_nisab']
3362                    if debug:
3363                        pp().pprint(report)
3364                    result = self.zakat(report, debug=debug)
3365                    if debug:
3366                        print('zakat-result', result, case[4])
3367                    assert result == case[4]
3368                    report = self.check(2.17, None, debug)
3369                    (valid, brief, plan) = report
3370                    assert valid is False
3371
3372            history_size = len(self._vault['history'])
3373            if debug:
3374                print('history_size', history_size)
3375            assert history_size == 3
3376            assert not self.nolock()
3377            assert self.recall(False, debug) is False
3378            self.free(self.lock())
3379            assert self.nolock()
3380
3381            for i in range(3, 0, -1):
3382                history_size = len(self._vault['history'])
3383                if debug:
3384                    print('history_size', history_size)
3385                assert history_size == i
3386                assert self.recall(False, debug) is True
3387
3388            assert self.nolock()
3389            assert self.recall(False, debug) is False
3390
3391            history_size = len(self._vault['history'])
3392            if debug:
3393                print('history_size', history_size)
3394            assert history_size == 0
3395
3396            account_size = len(self._vault['account'])
3397            if debug:
3398                print('account_size', account_size)
3399            assert account_size == 0
3400
3401            report_size = len(self._vault['report'])
3402            if debug:
3403                print('report_size', report_size)
3404            assert report_size == 0
3405
3406            assert self.nolock()
3407            return True
3408        except Exception as e:
3409            # pp().pprint(self._vault)
3410            assert self.export_json("test-snapshot.json")
3411            assert self.save(f"test-snapshot.{self.ext()}")
3412            raise e
def test(debug: bool = False):
3415def test(debug: bool = False):
3416    ledger = ZakatTracker("./zakat_test_db/zakat.camel")
3417    start = ZakatTracker.time()
3418    assert ledger.test(debug=debug)
3419    if debug:
3420        print("#########################")
3421        print("######## TEST DONE ########")
3422        print("#########################")
3423        print(ZakatTracker.duration_from_nanoseconds(ZakatTracker.time() - start))
3424        print("#########################")
class Action(enum.Enum):
87class Action(Enum):
88    CREATE = auto()
89    TRACK = auto()
90    LOG = auto()
91    SUB = auto()
92    ADD_FILE = auto()
93    REMOVE_FILE = auto()
94    BOX_TRANSFER = auto()
95    EXCHANGE = auto()
96    REPORT = auto()
97    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):
100class JSONEncoder(json.JSONEncoder):
101    def default(self, obj):
102        if isinstance(obj, Action) or isinstance(obj, MathOperation):
103            return obj.name  # Serialize as the enum member's name
104        elif isinstance(obj, Decimal):
105            return float(obj)
106        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):
101    def default(self, obj):
102        if isinstance(obj, Action) or isinstance(obj, MathOperation):
103            return obj.name  # Serialize as the enum member's name
104        elif isinstance(obj, Decimal):
105            return float(obj)
106        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):
109class MathOperation(Enum):
110    ADDITION = auto()
111    EQUAL = auto()
112    SUBTRACTION = auto()
ADDITION = <MathOperation.ADDITION: 1>
EQUAL = <MathOperation.EQUAL: 2>
SUBTRACTION = <MathOperation.SUBTRACTION: 3>
class WeekDay(enum.Enum):
77class WeekDay(Enum):
78    Monday = 0
79    Tuesday = 1
80    Wednesday = 2
81    Thursday = 3
82    Friday = 4
83    Saturday = 5
84    Sunday = 6
Monday = <WeekDay.Monday: 0>
Tuesday = <WeekDay.Tuesday: 1>
Wednesday = <WeekDay.Wednesday: 2>
Thursday = <WeekDay.Thursday: 3>
Friday = <WeekDay.Friday: 4>
Saturday = <WeekDay.Saturday: 5>
Sunday = <WeekDay.Sunday: 6>
def start_file_server( database_path: str, database_callback: <built-in function callable> = None, csv_callback: <built-in function callable> = None, debug: bool = False) -> tuple:
 55def start_file_server(database_path: str, database_callback: callable = None, csv_callback: callable = None,
 56                      debug: bool = False) -> tuple:
 57    """
 58    Starts a multi-purpose 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'>