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

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_init(self) -> dict[str, tuple[int, str]]:
567    def stats_init(self) -> dict[str, tuple[int, str]]:
568        """
569        Initialize and return a dictionary containing initial statistics for the ZakatTracker instance.
570
571        The dictionary contains two keys: 'database' and 'ram'. Each key maps to a tuple containing two elements:
572        - The initial size of the respective statistic in bytes (int).
573        - The initial size of the respective statistic in a human-readable format (str).
574
575        Returns:
576        dict[str, tuple]: A dictionary with initial statistics for the ZakatTracker instance.
577        """
578        return {
579            'database': (0, '0'),
580            'ram': (0, '0'),
581        }

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

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

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

Returns: dict[str, tuple]: A dictionary with initial statistics for the ZakatTracker instance.

def stats(self, ignore_ram: bool = True) -> dict[str, tuple[int, str]]:
583    def stats(self, ignore_ram: bool = True) -> dict[str, tuple[int, str]]:
584        """
585        Calculates and returns statistics about the object's data storage.
586
587        This method determines the size of the database file on disk and the
588        size of the data currently held in RAM (likely within a dictionary).
589        Both sizes are reported in bytes and in a human-readable format
590        (e.g., KB, MB).
591
592        Parameters:
593        ignore_ram (bool): Whether to ignore the RAM size. Default is True
594
595        Returns:
596        dict[str, tuple]: A dictionary containing the following statistics:
597
598            * 'database': A tuple with two elements:
599                - The database file size in bytes (int).
600                - The database file size in human-readable format (str).
601            * 'ram': A tuple with two elements:
602                - The RAM usage (dictionary size) in bytes (int).
603                - The RAM usage in human-readable format (str).
604
605        Example:
606        >>> stats = my_object.stats()
607        >>> print(stats['database'])
608        (256000, '250.0 KB')
609        >>> print(stats['ram'])
610        (12345, '12.1 KB')
611        """
612        ram_size = 0.0 if ignore_ram else self.get_dict_size(self.vault())
613        file_size = os.path.getsize(self.path())
614        return {
615            'database': (file_size, self.human_readable_size(file_size)),
616            'ram': (ram_size, self.human_readable_size(ram_size)),
617        }

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

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

Parameters: ignore_ram (bool): Whether to ignore the RAM size. Default is True

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]]:
619    def files(self) -> list[dict[str, str | int]]:
620        """
621        Retrieves information about files associated with this class.
622
623        This class method provides a standardized way to gather details about
624        files used by the class for storage, snapshots, and CSV imports.
625
626        Returns:
627        list[dict[str, str | int]]: A list of dictionaries, each containing information
628            about a specific file:
629
630            * type (str): The type of file ('database', 'snapshot', 'import_csv').
631            * path (str): The full file path.
632            * exists (bool): Whether the file exists on the filesystem.
633            * size (int): The file size in bytes (0 if the file doesn't exist).
634            * human_readable_size (str): A human-friendly representation of the file size (e.g., '10 KB', '2.5 MB').
635
636        Example:
637        ```
638        file_info = MyClass.files()
639        for info in file_info:
640            print(f"Type: {info['type']}, Exists: {info['exists']}, Size: {info['human_readable_size']}")
641        ```
642        """
643        result = []
644        for file_type, path in {
645            'database': self.path(),
646            'snapshot': self.snapshot_cache_path(),
647            'import_csv': self.import_csv_cache_path(),
648        }.items():
649            exists = os.path.exists(path)
650            size = os.path.getsize(path) if exists else 0
651            human_readable_size = self.human_readable_size(size) if exists else 0
652            result.append({
653                'type': file_type,
654                'path': path,
655                'exists': exists,
656                'size': size,
657                'human_readable_size': human_readable_size,
658            })
659        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:
661    def steps(self) -> dict:
662        """
663        Returns a copy of the history of steps taken in the ZakatTracker.
664
665        The history is a dictionary where each key is a unique identifier for a step,
666        and the corresponding value is a dictionary containing information about the step.
667
668        Returns:
669        dict: A copy of the history of steps taken in the ZakatTracker.
670        """
671        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:
673    def free(self, lock: int, auto_save: bool = True) -> bool:
674        """
675        Releases the lock on the database.
676
677        Parameters:
678        lock (int): The lock ID to be released.
679        auto_save (bool): Whether to automatically save the database after releasing the lock.
680
681        Returns:
682        bool: True if the lock is successfully released and (optionally) saved, False otherwise.
683        """
684        if lock == self._vault['lock']:
685            self._vault['lock'] = None
686            self.clean_history(lock)
687            if auto_save:
688                return self.save(self.path())
689            return True
690        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:
692    def account_exists(self, account) -> bool:
693        """
694        Check if the given account exists in the vault.
695
696        Parameters:
697        account (str): The account number to check.
698
699        Returns:
700        bool: True if the account exists, False otherwise.
701        """
702        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:
704    def box_size(self, account) -> int:
705        """
706        Calculate the size of the box for a specific account.
707
708        Parameters:
709        account (str): The account number for which the box size needs to be calculated.
710
711        Returns:
712        int: The size of the box for the given account. If the account does not exist, -1 is returned.
713        """
714        if self.account_exists(account):
715            return len(self._vault['account'][account]['box'])
716        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:
718    def log_size(self, account) -> int:
719        """
720        Get the size of the log for a specific account.
721
722        Parameters:
723        account (str): The account number for which the log size needs to be calculated.
724
725        Returns:
726        int: The size of the log for the given account. If the account does not exist, -1 is returned.
727        """
728        if self.account_exists(account):
729            return len(self._vault['account'][account]['log'])
730        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:
732    @staticmethod
733    def file_hash(file_path: str, algorithm: str = "blake2b") -> str:
734        """
735        Calculates the hash of a file using the specified algorithm.
736
737        Parameters:
738        file_path (str): The path to the file.
739        algorithm (str, optional): The hashing algorithm to use. Defaults to "blake2b".
740
741        Returns:
742        str: The hexadecimal representation of the file's hash.
743        """
744        hash_obj = hashlib.new(algorithm)  # Create the hash object
745        with open(file_path, "rb") as f:  # Open file in binary mode for reading
746            for chunk in iter(lambda: f.read(4096), b""):  # Read file in chunks
747                hash_obj.update(chunk)
748        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):
750    def snapshot_cache_path(self):
751        """
752        Generate the path for the cache file used to store snapshots.
753
754        The cache file is a camel file that stores the timestamps of the snapshots.
755        The file name is derived from the main database file name by replacing the ".camel" extension with ".snapshots.camel".
756
757        Returns:
758        str: The path to the cache file.
759        """
760        path = str(self.path())
761        ext = self.ext()
762        ext_len = len(ext)
763        if path.endswith(f'.{ext}'):
764            path = path[:-ext_len - 1]
765        _, filename = os.path.split(path + f'.snapshots.{ext}')
766        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:
768    def snapshot(self) -> bool:
769        """
770        This function creates a snapshot of the current database state.
771
772        The function calculates the hash of the current database file and checks if a snapshot with the same hash already exists.
773        If a snapshot with the same hash exists, the function returns True without creating a new snapshot.
774        If a snapshot with the same hash does not exist, the function creates a new snapshot by saving the current database state
775        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.
776
777        Parameters:
778        None
779
780        Returns:
781        bool: True if a snapshot with the same hash already exists or if the snapshot is successfully created. False if the snapshot creation fails.
782        """
783        current_hash = self.file_hash(self.path())
784        cache: dict[str, int] = {}  # hash: time_ns
785        try:
786            with open(self.snapshot_cache_path(), 'r') as stream:
787                cache = camel.load(stream.read())
788        except:
789            pass
790        if current_hash in cache:
791            return True
792        time = time_ns()
793        cache[current_hash] = time
794        if not self.save(self.base_path('snapshots', f'{time}.{self.ext()}')):
795            return False
796        with open(self.snapshot_cache_path(), 'w') as stream:
797            stream.write(camel.dump(cache))
798        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]]:
800    def snapshots(self, hide_missing: bool = True, verified_hash_only: bool = False) \
801            -> dict[int, tuple[str, str, bool]]:
802        """
803        Retrieve a dictionary of snapshots, with their respective hashes, paths, and existence status.
804
805        Parameters:
806        - hide_missing (bool): If True, only include snapshots that exist in the dictionary. Default is True.
807        - verified_hash_only (bool): If True, only include snapshots with a valid hash. Default is False.
808
809        Returns:
810        - dict[int, tuple[str, str, bool]]: A dictionary where the keys are the timestamps of the snapshots,
811        and the values are tuples containing the snapshot's hash, path, and existence status.
812        """
813        cache: dict[str, int] = {}  # hash: time_ns
814        try:
815            with open(self.snapshot_cache_path(), 'r') as stream:
816                cache = camel.load(stream.read())
817        except:
818            pass
819        if not cache:
820            return {}
821        result: dict[int, tuple[str, str, bool]] = {}  # time_ns: (hash, path, exists)
822        for file_hash, ref in cache.items():
823            path = self.base_path('snapshots', f'{ref}.{self.ext()}')
824            exists = os.path.exists(path)
825            valid_hash = self.file_hash(path) == file_hash if verified_hash_only else True
826            if (verified_hash_only and not valid_hash) or (verified_hash_only and not exists):
827                continue
828            if exists or not hide_missing:
829                result[ref] = (file_hash, path, exists)
830        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:
832    def recall(self, dry=True, debug=False) -> bool:
833        """
834        Revert the last operation.
835
836        Parameters:
837        dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
838        debug (bool): If True, the function will print debug information. Default is False.
839
840        Returns:
841        bool: True if the operation was successful, False otherwise.
842        """
843        if not self.nolock() or len(self._vault['history']) == 0:
844            return False
845        if len(self._vault['history']) <= 0:
846            return False
847        ref = sorted(self._vault['history'].keys())[-1]
848        if debug:
849            print('recall', ref)
850        memory = self._vault['history'][ref]
851        if debug:
852            print(type(memory), 'memory', memory)
853        limit = len(memory) + 1
854        sub_positive_log_negative = 0
855        for i in range(-1, -limit, -1):
856            x = memory[i]
857            if debug:
858                print(type(x), x)
859            match x['action']:
860                case Action.CREATE:
861                    if x['account'] is not None:
862                        if self.account_exists(x['account']):
863                            if debug:
864                                print('account', self._vault['account'][x['account']])
865                            assert len(self._vault['account'][x['account']]['box']) == 0
866                            assert self._vault['account'][x['account']]['balance'] == 0
867                            assert self._vault['account'][x['account']]['count'] == 0
868                            if dry:
869                                continue
870                            del self._vault['account'][x['account']]
871
872                case Action.TRACK:
873                    if x['account'] is not None:
874                        if self.account_exists(x['account']):
875                            if dry:
876                                continue
877                            self._vault['account'][x['account']]['balance'] -= x['value']
878                            self._vault['account'][x['account']]['count'] -= 1
879                            del self._vault['account'][x['account']]['box'][x['ref']]
880
881                case Action.LOG:
882                    if x['account'] is not None:
883                        if self.account_exists(x['account']):
884                            if x['ref'] in self._vault['account'][x['account']]['log']:
885                                if dry:
886                                    continue
887                                if sub_positive_log_negative == -x['value']:
888                                    self._vault['account'][x['account']]['count'] -= 1
889                                    sub_positive_log_negative = 0
890                                box_ref = self._vault['account'][x['account']]['log'][x['ref']]['ref']
891                                if not box_ref is None:
892                                    assert self.box_exists(x['account'], box_ref)
893                                    box_value = self._vault['account'][x['account']]['log'][x['ref']]['value']
894                                    assert box_value < 0
895
896                                    try:
897                                        self._vault['account'][x['account']]['box'][box_ref]['rest'] += -box_value
898                                    except TypeError:
899                                        self._vault['account'][x['account']]['box'][box_ref]['rest'] += Decimal(
900                                            -box_value)
901
902                                    try:
903                                        self._vault['account'][x['account']]['balance'] += -box_value
904                                    except TypeError:
905                                        self._vault['account'][x['account']]['balance'] += Decimal(-box_value)
906
907                                    self._vault['account'][x['account']]['count'] -= 1
908                                del self._vault['account'][x['account']]['log'][x['ref']]
909
910                case Action.SUB:
911                    if x['account'] is not None:
912                        if self.account_exists(x['account']):
913                            if x['ref'] in self._vault['account'][x['account']]['box']:
914                                if dry:
915                                    continue
916                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] += x['value']
917                                self._vault['account'][x['account']]['balance'] += x['value']
918                                sub_positive_log_negative = x['value']
919
920                case Action.ADD_FILE:
921                    if x['account'] is not None:
922                        if self.account_exists(x['account']):
923                            if x['ref'] in self._vault['account'][x['account']]['log']:
924                                if x['file'] in self._vault['account'][x['account']]['log'][x['ref']]['file']:
925                                    if dry:
926                                        continue
927                                    del self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']]
928
929                case Action.REMOVE_FILE:
930                    if x['account'] is not None:
931                        if self.account_exists(x['account']):
932                            if x['ref'] in self._vault['account'][x['account']]['log']:
933                                if dry:
934                                    continue
935                                self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']] = x['value']
936
937                case Action.BOX_TRANSFER:
938                    if x['account'] is not None:
939                        if self.account_exists(x['account']):
940                            if x['ref'] in self._vault['account'][x['account']]['box']:
941                                if dry:
942                                    continue
943                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] -= x['value']
944
945                case Action.EXCHANGE:
946                    if x['account'] is not None:
947                        if x['account'] in self._vault['exchange']:
948                            if x['ref'] in self._vault['exchange'][x['account']]:
949                                if dry:
950                                    continue
951                                del self._vault['exchange'][x['account']][x['ref']]
952
953                case Action.REPORT:
954                    if x['ref'] in self._vault['report']:
955                        if dry:
956                            continue
957                        del self._vault['report'][x['ref']]
958
959                case Action.ZAKAT:
960                    if x['account'] is not None:
961                        if self.account_exists(x['account']):
962                            if x['ref'] in self._vault['account'][x['account']]['box']:
963                                if x['key'] in self._vault['account'][x['account']]['box'][x['ref']]:
964                                    if dry:
965                                        continue
966                                    match x['math']:
967                                        case MathOperation.ADDITION:
968                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] -= x[
969                                                'value']
970                                        case MathOperation.EQUAL:
971                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] = x['value']
972                                        case MathOperation.SUBTRACTION:
973                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] += x[
974                                                'value']
975
976        if not dry:
977            del self._vault['history'][ref]
978        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:
980    def ref_exists(self, account: str, ref_type: str, ref: int) -> bool:
981        """
982        Check if a specific reference (transaction) exists in the vault for a given account and reference type.
983
984        Parameters:
985        account (str): The account number for which to check the existence of the reference.
986        ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
987        ref (int): The reference (transaction) number to check for existence.
988
989        Returns:
990        bool: True if the reference exists for the given account and reference type, False otherwise.
991        """
992        if account in self._vault['account']:
993            return ref in self._vault['account'][account][ref_type]
994        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:
 996    def box_exists(self, account: str, ref: int) -> bool:
 997        """
 998        Check if a specific box (transaction) exists in the vault for a given account and reference.
 999
1000        Parameters:
1001        - account (str): The account number for which to check the existence of the box.
1002        - ref (int): The reference (transaction) number to check for existence.
1003
1004        Returns:
1005        - bool: True if the box exists for the given account and reference, False otherwise.
1006        """
1007        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:
1009    def track(self, unscaled_value: float | int | Decimal = 0, desc: str = '', account: str = 1, logging: bool = True,
1010              created: int = None,
1011              debug: bool = False) -> int:
1012        """
1013        This function tracks a transaction for a specific account.
1014
1015        Parameters:
1016        unscaled_value (float | int | Decimal): The value of the transaction. Default is 0.
1017        desc (str): The description of the transaction. Default is an empty string.
1018        account (str): The account for which the transaction is being tracked. Default is '1'.
1019        logging (bool): Whether to log the transaction. Default is True.
1020        created (int): The timestamp of the transaction. If not provided, it will be generated. Default is None.
1021        debug (bool): Whether to print debug information. Default is False.
1022
1023        Returns:
1024        int: The timestamp of the transaction.
1025
1026        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.
1027
1028        Raises:
1029        ValueError: The log transaction happened again in the same nanosecond time.
1030        ValueError: The box transaction happened again in the same nanosecond time.
1031        """
1032        if debug:
1033            print('track', f'unscaled_value={unscaled_value}, debug={debug}')
1034        if created is None:
1035            created = self.time()
1036        no_lock = self.nolock()
1037        self.lock()
1038        if not self.account_exists(account):
1039            if debug:
1040                print(f"account {account} created")
1041            self._vault['account'][account] = {
1042                'balance': 0,
1043                'box': {},
1044                'count': 0,
1045                'log': {},
1046                'hide': False,
1047                'zakatable': True,
1048            }
1049            self._step(Action.CREATE, account)
1050        if unscaled_value == 0:
1051            if no_lock:
1052                self.free(self.lock())
1053            return 0
1054        value = self.scale(unscaled_value)
1055        if logging:
1056            self._log(value=value, desc=desc, account=account, created=created, ref=None, debug=debug)
1057        if debug:
1058            print('create-box', created)
1059        if self.box_exists(account, created):
1060            raise ValueError(f"The box transaction happened again in the same nanosecond time({created}).")
1061        if debug:
1062            print('created-box', created)
1063        self._vault['account'][account]['box'][created] = {
1064            'capital': value,
1065            'count': 0,
1066            'last': 0,
1067            'rest': value,
1068            'total': 0,
1069        }
1070        self._step(Action.TRACK, account, ref=created, value=value)
1071        if no_lock:
1072            self.free(self.lock())
1073        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:
1075    def log_exists(self, account: str, ref: int) -> bool:
1076        """
1077        Checks if a specific transaction log entry exists for a given account.
1078
1079        Parameters:
1080        account (str): The account number associated with the transaction log.
1081        ref (int): The reference to the transaction log entry.
1082
1083        Returns:
1084        bool: True if the transaction log entry exists, False otherwise.
1085        """
1086        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:
1134    def exchange(self, account, created: int = None, rate: float = None, description: str = None,
1135                 debug: bool = False) -> dict:
1136        """
1137        This method is used to record or retrieve exchange rates for a specific account.
1138
1139        Parameters:
1140        - account (str): The account number for which the exchange rate is being recorded or retrieved.
1141        - created (int): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
1142        - rate (float): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
1143        - description (str): A description of the exchange rate.
1144        - debug (bool): Whether to print debug information. Default is False.
1145
1146        Returns:
1147        - dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found,
1148        it returns a dictionary with default values for the rate and description.
1149        """
1150        if debug:
1151            print('exchange', f'debug={debug}')
1152        if created is None:
1153            created = self.time()
1154        no_lock = self.nolock()
1155        self.lock()
1156        if rate is not None:
1157            if rate <= 0:
1158                return dict()
1159            if account not in self._vault['exchange']:
1160                self._vault['exchange'][account] = {}
1161            if len(self._vault['exchange'][account]) == 0 and rate <= 1:
1162                return {"time": created, "rate": 1, "description": None}
1163            self._vault['exchange'][account][created] = {"rate": rate, "description": description}
1164            self._step(Action.EXCHANGE, account, ref=created, value=rate)
1165            if no_lock:
1166                self.free(self.lock())
1167            if debug:
1168                print("exchange-created-1",
1169                      f'account: {account}, created: {created}, rate:{rate}, description:{description}')
1170
1171        if account in self._vault['exchange']:
1172            valid_rates = [(ts, r) for ts, r in self._vault['exchange'][account].items() if ts <= created]
1173            if valid_rates:
1174                latest_rate = max(valid_rates, key=lambda x: x[0])
1175                if debug:
1176                    print("exchange-read-1",
1177                          f'account: {account}, created: {created}, rate:{rate}, description:{description}',
1178                          'latest_rate', latest_rate)
1179                result = latest_rate[1]
1180                result['time'] = latest_rate[0]
1181                return result  # إرجاع قاموس يحتوي على المعدل والوصف
1182        if debug:
1183            print("exchange-read-0", f'account: {account}, created: {created}, rate:{rate}, description:{description}')
1184        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:
1186    @staticmethod
1187    def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
1188        """
1189        This function calculates the exchanged amount of a currency.
1190
1191        Args:
1192            x (float): The original amount of the currency.
1193            x_rate (float): The exchange rate of the original currency.
1194            y_rate (float): The exchange rate of the target currency.
1195
1196        Returns:
1197            float: The exchanged amount of the target currency.
1198        """
1199        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:
1201    def exchanges(self) -> dict:
1202        """
1203        Retrieve the recorded exchange rates for all accounts.
1204
1205        Parameters:
1206        None
1207
1208        Returns:
1209        dict: A dictionary containing all recorded exchange rates.
1210        The keys are account names or numbers, and the values are dictionaries containing the exchange rates.
1211        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
1212        """
1213        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:
1215    def accounts(self) -> dict:
1216        """
1217        Returns a dictionary containing account numbers as keys and their respective balances as values.
1218
1219        Parameters:
1220        None
1221
1222        Returns:
1223        dict: A dictionary where keys are account numbers and values are their respective balances.
1224        """
1225        result = {}
1226        for i in self._vault['account']:
1227            result[i] = self._vault['account'][i]['balance']
1228        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:
1230    def boxes(self, account) -> dict:
1231        """
1232        Retrieve the boxes (transactions) associated with a specific account.
1233
1234        Parameters:
1235        account (str): The account number for which to retrieve the boxes.
1236
1237        Returns:
1238        dict: A dictionary containing the boxes associated with the given account.
1239        If the account does not exist, an empty dictionary is returned.
1240        """
1241        if self.account_exists(account):
1242            return self._vault['account'][account]['box']
1243        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:
1245    def logs(self, account) -> dict:
1246        """
1247        Retrieve the logs (transactions) associated with a specific account.
1248
1249        Parameters:
1250        account (str): The account number for which to retrieve the logs.
1251
1252        Returns:
1253        dict: A dictionary containing the logs associated with the given account.
1254        If the account does not exist, an empty dictionary is returned.
1255        """
1256        if self.account_exists(account):
1257            return self._vault['account'][account]['log']
1258        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_init(self) -> dict[str, dict]:
1260    def daily_logs_init(self) -> dict[str, dict]:
1261        """
1262        Initialize a dictionary to store daily, weekly, monthly, and yearly logs.
1263
1264        Returns:
1265        dict: A dictionary with keys 'daily', 'weekly', 'monthly', and 'yearly', each containing an empty dictionary.
1266            Later each key maps to another dictionary, which will store the logs for the corresponding time period.
1267        """
1268        return {
1269            'daily': {},
1270            'weekly': {},
1271            'monthly': {},
1272            'yearly': {},
1273        }

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

Returns: dict: A dictionary with keys 'daily', 'weekly', 'monthly', and 'yearly', each containing an empty dictionary. Later each key maps to another dictionary, which will store the logs for the corresponding time period.

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