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

Returns the current version of the software.

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

Returns: str: The current version of the software.

@staticmethod
def ZakatCut(x: float) -> float:
226    @staticmethod
227    def ZakatCut(x: float) -> float:
228        """
229        Calculates the Zakat amount due on an asset.
230
231        This function calculates the zakat amount due on a given asset value over one lunar year.
232        Zakat is an Islamic obligatory alms-giving, calculated as a fixed percentage of an individual's wealth
233        that exceeds a certain threshold (Nisab).
234
235        Parameters:
236        x: The total value of the asset on which Zakat is to be calculated.
237
238        Returns:
239        The amount of Zakat due on the asset, calculated as 2.5% of the asset's value.
240        """
241        return 0.025 * x  # Zakat Cut in one Lunar Year

Calculates the Zakat amount due on an asset.

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

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

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

@staticmethod
def TimeCycle(days: int = 355) -> int:
243    @staticmethod
244    def TimeCycle(days: int = 355) -> int:
245        """
246        Calculates the approximate duration of a lunar year in nanoseconds.
247
248        This function calculates the approximate duration of a lunar year based on the given number of days.
249        It converts the given number of days into nanoseconds for use in high-precision timing applications.
250
251        Parameters:
252        days: The number of days in a lunar year. Defaults to 355,
253              which is an approximation of the average length of a lunar year.
254
255        Returns:
256        The approximate duration of a lunar year in nanoseconds.
257        """
258        return int(60 * 60 * 24 * days * 1e9)  # Lunar Year in nanoseconds

Calculates the approximate duration of a lunar year in nanoseconds.

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

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

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

@staticmethod
def Nisab(gram_price: float, gram_quantity: float = 595) -> float:
260    @staticmethod
261    def Nisab(gram_price: float, gram_quantity: float = 595) -> float:
262        """
263        Calculate the total value of Nisab (a unit of weight in Islamic jurisprudence) based on the given price per gram.
264
265        This function calculates the Nisab value, which is the minimum threshold of wealth,
266        that makes an individual liable for paying Zakat.
267        The Nisab value is determined by the equivalent value of a specific amount
268        of gold or silver (currently 595 grams in silver) in the local currency.
269
270        Parameters:
271        - gram_price (float): The price per gram of Nisab.
272        - gram_quantity (float): The quantity of grams in a Nisab. Default is 595 grams of silver.
273
274        Returns:
275        - float: The total value of Nisab based on the given price per gram.
276        """
277        return gram_price * gram_quantity

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

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

Parameters:

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

Returns:

  • float: The total value of Nisab based on the given price per gram.
@staticmethod
def ext() -> str:
279    @staticmethod
280    def ext() -> str:
281        """
282        Returns the file extension used by the ZakatTracker class.
283
284        Returns:
285        str: The file extension used by the ZakatTracker class, which is 'camel'.
286        """
287        return 'camel'

Returns the file extension used by the ZakatTracker class.

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

def path(self, path: str = None) -> str:
307    def path(self, path: str = None) -> str:
308        """
309        Set or get the path to the database file.
310
311        If no path is provided, the current path is returned.
312        If a path is provided, it is set as the new path.
313        The function also creates the necessary directories if the provided path is a file.
314
315        Parameters:
316        path (str): The new path to the database file. If not provided, the current path is returned.
317
318        Returns:
319        str: The current or new path to the database file.
320        """
321        if path is None:
322            return self._vault_path
323        self._vault_path = Path(path).resolve()
324        base_path = Path(path).resolve()
325        if base_path.is_file() or base_path.suffix:
326            base_path = base_path.parent
327        base_path.mkdir(parents=True, exist_ok=True)
328        self._base_path = base_path
329        return str(self._vault_path)

Set or get the path to the database file.

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

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

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

def base_path(self, *args) -> str:
331    def base_path(self, *args) -> str:
332        """
333        Generate a base path by joining the provided arguments with the existing base path.
334
335        Parameters:
336        *args (str): Variable length argument list of strings to be joined with the base path.
337
338        Returns:
339        str: The generated base path. If no arguments are provided, the existing base path is returned.
340        """
341        if not args:
342            return str(self._base_path)
343        filtered_args = []
344        ignored_filename = None
345        for arg in args:
346            if Path(arg).suffix:
347                ignored_filename = arg
348            else:
349                filtered_args.append(arg)
350        base_path = Path(self._base_path)
351        full_path = base_path.joinpath(*filtered_args)
352        full_path.mkdir(parents=True, exist_ok=True)
353        if ignored_filename is not None:
354            return full_path.resolve() / ignored_filename  # Join with the ignored filename
355        return str(full_path.resolve())

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

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

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

@staticmethod
def scale(x: float | int | decimal.Decimal, decimal_places: int = 2) -> int:
357    @staticmethod
358    def scale(x: float | int | Decimal, decimal_places: int = 2) -> int:
359        """
360        Scales a numerical value by a specified power of 10, returning an integer.
361
362        This function is designed to handle various numeric types (`float`, `int`, or `Decimal`) and
363        facilitate precise scaling operations, particularly useful in financial or scientific calculations.
364
365        Parameters:
366        x: The numeric value to scale. Can be a floating-point number, integer, or decimal.
367        decimal_places: The exponent for the scaling factor (10**y). Defaults to 2, meaning the input is scaled
368            by a factor of 100 (e.g., converts 1.23 to 123).
369
370        Returns:
371        The scaled value, rounded to the nearest integer.
372
373        Raises:
374        TypeError: If the input `x` is not a valid numeric type.
375
376        Examples:
377        >>> ZakatTracker.scale(3.14159)
378        314
379        >>> ZakatTracker.scale(1234, decimal_places=3)
380        1234000
381        >>> ZakatTracker.scale(Decimal("0.005"), decimal_places=4)
382        50
383        """
384        if not isinstance(x, (float, int, Decimal)):
385            raise TypeError("Input 'x' must be a float, int, or Decimal.")
386        return int(Decimal(f"{x:.{decimal_places}f}") * (10 ** decimal_places))

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

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

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

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

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

Examples:

>>> ZakatTracker.scale(3.14159)
314
>>> ZakatTracker.scale(1234, decimal_places=3)
1234000
>>> ZakatTracker.scale(Decimal("0.005"), decimal_places=4)
50
@staticmethod
def unscale( x: int, return_type: type = <class 'float'>, decimal_places: int = 2) -> float | decimal.Decimal:
388    @staticmethod
389    def unscale(x: int, return_type: type = float, decimal_places: int = 2) -> float | Decimal:
390        """
391        Unscales an integer by a power of 10.
392
393        Parameters:
394        x: The integer to unscale.
395        return_type: The desired type for the returned value. Can be float, int, or Decimal. Defaults to float.
396        decimal_places: The power of 10 to use. Defaults to 2.
397
398        Returns:
399        The unscaled number, converted to the specified return_type.
400
401        Raises:
402        TypeError: If the return_type is not float or Decimal.
403        """
404        if return_type not in (float, Decimal):
405            raise TypeError(f'Invalid return_type({return_type}). Supported types are float, int, and Decimal.')
406        return round(return_type(x / (10 ** decimal_places)), decimal_places)

Unscales an integer by a power of 10.

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

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

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

def reset(self) -> None:
422    def reset(self) -> None:
423        """
424        Reset the internal data structure to its initial state.
425
426        Parameters:
427        None
428
429        Returns:
430        None
431        """
432        self._vault = {
433            'account': {},
434            'exchange': {},
435            'history': {},
436            'lock': None,
437            'report': {},
438        }

Reset the internal data structure to its initial state.

Parameters: None

Returns: None

@staticmethod
def time( now: <module 'datetime' from '/opt/hostedtoolcache/Python/3.12.10/x64/lib/python3.12/datetime.py'> = None) -> int:
440    @staticmethod
441    def time(now: datetime = None) -> int:
442        """
443        Generates a timestamp based on the provided datetime object or the current datetime.
444
445        Parameters:
446        now (datetime, optional): The datetime object to generate the timestamp from.
447        If not provided, the current datetime is used.
448
449        Returns:
450        int: The timestamp in positive nanoseconds since the Unix epoch (January 1, 1970),
451            before 1970 will return in negative until 1000AD.
452        """
453        if now is None:
454            now = datetime.datetime.now()
455        ordinal_day = now.toordinal()
456        ns_in_day = (now - now.replace(hour=0, minute=0, second=0, microsecond=0)).total_seconds() * 10 ** 9
457        return int((ordinal_day - 719_163) * 86_400_000_000_000 + ns_in_day)

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

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

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

@staticmethod
def time_to_datetime( ordinal_ns: int) -> <module 'datetime' from '/opt/hostedtoolcache/Python/3.12.10/x64/lib/python3.12/datetime.py'>:
459    @staticmethod
460    def time_to_datetime(ordinal_ns: int) -> datetime:
461        """
462        Converts an ordinal number (number of days since 1000-01-01) to a datetime object.
463
464        Parameters:
465        ordinal_ns (int): The ordinal number of days since 1000-01-01.
466
467        Returns:
468        datetime: The corresponding datetime object.
469        """
470        ordinal_day = ordinal_ns // 86_400_000_000_000 + 719_163
471        ns_in_day = ordinal_ns % 86_400_000_000_000
472        d = datetime.datetime.fromordinal(ordinal_day)
473        t = datetime.timedelta(seconds=ns_in_day // 10 ** 9)
474        return datetime.datetime.combine(d, datetime.time()) + t

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

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

Returns: datetime: The corresponding datetime object.

def clean_history(self, lock: int | None = None) -> int:
476    def clean_history(self, lock: int | None = None) -> int:
477        """
478        Cleans up the history of actions performed on the ZakatTracker instance.
479
480        Parameters:
481        lock (int, optional): The lock ID is used to clean up the empty history.
482            If not provided, it cleans up the empty history records for all locks.
483
484        Returns:
485        int: The number of locks cleaned up.
486        """
487        count = 0
488        if lock in self._vault['history']:
489            if len(self._vault['history'][lock]) <= 0:
490                count += 1
491                del self._vault['history'][lock]
492            return count
493        self.free(self.lock())
494        for lock in self._vault['history']:
495            if len(self._vault['history'][lock]) <= 0:
496                count += 1
497                del self._vault['history'][lock]
498        return count

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

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

Returns: int: The number of locks cleaned up.

def nolock(self) -> bool:
536    def nolock(self) -> bool:
537        """
538        Check if the vault lock is currently not set.
539
540        Returns:
541        bool: True if the vault lock is not set, False otherwise.
542        """
543        return self._vault['lock'] is None

Check if the vault lock is currently not set.

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

def lock(self) -> int:
545    def lock(self) -> int:
546        """
547        Acquires a lock on the ZakatTracker instance.
548
549        Returns:
550        int: The lock ID. This ID can be used to release the lock later.
551        """
552        return self._step()

Acquires a lock on the ZakatTracker instance.

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

def vault(self) -> dict:
554    def vault(self) -> dict:
555        """
556        Returns a copy of the internal vault dictionary.
557
558        This method is used to retrieve the current state of the ZakatTracker object.
559        It provides a snapshot of the internal data structure, allowing for further
560        processing or analysis.
561
562        Returns:
563        dict: A copy of the internal vault dictionary.
564        """
565        return self._vault.copy()

Returns a copy of the internal vault dictionary.

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

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

def stats(self) -> dict[str, tuple]:
567    def stats(self) -> dict[str, tuple]:
568        """
569        Calculates and returns statistics about the object's data storage.
570
571        This method determines the size of the database file on disk and the
572        size of the data currently held in RAM (likely within a dictionary).
573        Both sizes are reported in bytes and in a human-readable format
574        (e.g., KB, MB).
575
576        Returns:
577        dict[str, tuple]: A dictionary containing the following statistics:
578
579            * 'database': A tuple with two elements:
580                - The database file size in bytes (int).
581                - The database file size in human-readable format (str).
582            * 'ram': A tuple with two elements:
583                - The RAM usage (dictionary size) in bytes (int).
584                - The RAM usage in human-readable format (str).
585
586        Example:
587        >>> stats = my_object.stats()
588        >>> print(stats['database'])
589        (256000, '250.0 KB')
590        >>> print(stats['ram'])
591        (12345, '12.1 KB')
592        """
593        ram_size = self.get_dict_size(self.vault())
594        file_size = os.path.getsize(self.path())
595        return {
596            'database': (file_size, self.human_readable_size(file_size)),
597            'ram': (ram_size, self.human_readable_size(ram_size)),
598        }

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

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

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

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

Example:

>>> stats = my_object.stats()
>>> print(stats['database'])
(256000, '250.0 KB')
>>> print(stats['ram'])
(12345, '12.1 KB')
def files(self) -> list[dict[str, str | int]]:
600    def files(self) -> list[dict[str, str | int]]:
601        """
602        Retrieves information about files associated with this class.
603
604        This class method provides a standardized way to gather details about
605        files used by the class for storage, snapshots, and CSV imports.
606
607        Returns:
608        list[dict[str, str | int]]: A list of dictionaries, each containing information
609            about a specific file:
610
611            * type (str): The type of file ('database', 'snapshot', 'import_csv').
612            * path (str): The full file path.
613            * exists (bool): Whether the file exists on the filesystem.
614            * size (int): The file size in bytes (0 if the file doesn't exist).
615            * human_readable_size (str): A human-friendly representation of the file size (e.g., '10 KB', '2.5 MB').
616
617        Example:
618        ```
619        file_info = MyClass.files()
620        for info in file_info:
621            print(f"Type: {info['type']}, Exists: {info['exists']}, Size: {info['human_readable_size']}")
622        ```
623        """
624        result = []
625        for file_type, path in {
626            'database': self.path(),
627            'snapshot': self.snapshot_cache_path(),
628            'import_csv': self.import_csv_cache_path(),
629        }.items():
630            exists = os.path.exists(path)
631            size = os.path.getsize(path) if exists else 0
632            human_readable_size = self.human_readable_size(size) if exists else 0
633            result.append({
634                'type': file_type,
635                'path': path,
636                'exists': exists,
637                'size': size,
638                'human_readable_size': human_readable_size,
639            })
640        return result

Retrieves information about files associated with this class.

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

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

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

Example:

file_info = MyClass.files()
for info in file_info:
    print(f"Type: {info['type']}, Exists: {info['exists']}, Size: {info['human_readable_size']}")
def steps(self) -> dict:
642    def steps(self) -> dict:
643        """
644        Returns a copy of the history of steps taken in the ZakatTracker.
645
646        The history is a dictionary where each key is a unique identifier for a step,
647        and the corresponding value is a dictionary containing information about the step.
648
649        Returns:
650        dict: A copy of the history of steps taken in the ZakatTracker.
651        """
652        return self._vault['history'].copy()

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

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

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

def free(self, lock: int, auto_save: bool = True) -> bool:
654    def free(self, lock: int, auto_save: bool = True) -> bool:
655        """
656        Releases the lock on the database.
657
658        Parameters:
659        lock (int): The lock ID to be released.
660        auto_save (bool): Whether to automatically save the database after releasing the lock.
661
662        Returns:
663        bool: True if the lock is successfully released and (optionally) saved, False otherwise.
664        """
665        if lock == self._vault['lock']:
666            self._vault['lock'] = None
667            self.clean_history(lock)
668            if auto_save:
669                return self.save(self.path())
670            return True
671        return False

Releases the lock on the database.

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

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

def account_exists(self, account) -> bool:
673    def account_exists(self, account) -> bool:
674        """
675        Check if the given account exists in the vault.
676
677        Parameters:
678        account (str): The account number to check.
679
680        Returns:
681        bool: True if the account exists, False otherwise.
682        """
683        return account in self._vault['account']

Check if the given account exists in the vault.

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

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

def box_size(self, account) -> int:
685    def box_size(self, account) -> int:
686        """
687        Calculate the size of the box for a specific account.
688
689        Parameters:
690        account (str): The account number for which the box size needs to be calculated.
691
692        Returns:
693        int: The size of the box for the given account. If the account does not exist, -1 is returned.
694        """
695        if self.account_exists(account):
696            return len(self._vault['account'][account]['box'])
697        return -1

Calculate the size of the box for a specific account.

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

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

def log_size(self, account) -> int:
699    def log_size(self, account) -> int:
700        """
701        Get the size of the log for a specific account.
702
703        Parameters:
704        account (str): The account number for which the log size needs to be calculated.
705
706        Returns:
707        int: The size of the log for the given account. If the account does not exist, -1 is returned.
708        """
709        if self.account_exists(account):
710            return len(self._vault['account'][account]['log'])
711        return -1

Get the size of the log for a specific account.

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

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

@staticmethod
def file_hash(file_path: str, algorithm: str = 'blake2b') -> str:
713    @staticmethod
714    def file_hash(file_path: str, algorithm: str = "blake2b") -> str:
715        """
716        Calculates the hash of a file using the specified algorithm.
717
718        Parameters:
719        file_path (str): The path to the file.
720        algorithm (str, optional): The hashing algorithm to use. Defaults to "blake2b".
721
722        Returns:
723        str: The hexadecimal representation of the file's hash.
724        """
725        hash_obj = hashlib.new(algorithm)  # Create the hash object
726        with open(file_path, "rb") as f:  # Open file in binary mode for reading
727            for chunk in iter(lambda: f.read(4096), b""):  # Read file in chunks
728                hash_obj.update(chunk)
729        return hash_obj.hexdigest()  # Return the hash as a hexadecimal string

Calculates the hash of a file using the specified algorithm.

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

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

def snapshot_cache_path(self):
731    def snapshot_cache_path(self):
732        """
733        Generate the path for the cache file used to store snapshots.
734
735        The cache file is a camel file that stores the timestamps of the snapshots.
736        The file name is derived from the main database file name by replacing the ".camel" extension with ".snapshots.camel".
737
738        Returns:
739        str: The path to the cache file.
740        """
741        path = str(self.path())
742        ext = self.ext()
743        ext_len = len(ext)
744        if path.endswith(f'.{ext}'):
745            path = path[:-ext_len - 1]
746        _, filename = os.path.split(path + f'.snapshots.{ext}')
747        return self.base_path(filename)

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

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

Returns: str: The path to the cache file.

def snapshot(self) -> bool:
749    def snapshot(self) -> bool:
750        """
751        This function creates a snapshot of the current database state.
752
753        The function calculates the hash of the current database file and checks if a snapshot with the same hash already exists.
754        If a snapshot with the same hash exists, the function returns True without creating a new snapshot.
755        If a snapshot with the same hash does not exist, the function creates a new snapshot by saving the current database state
756        in a new camel file with a unique timestamp as the file name. The function also updates the snapshot cache file with the new snapshot's hash and timestamp.
757
758        Parameters:
759        None
760
761        Returns:
762        bool: True if a snapshot with the same hash already exists or if the snapshot is successfully created. False if the snapshot creation fails.
763        """
764        current_hash = self.file_hash(self.path())
765        cache: dict[str, int] = {}  # hash: time_ns
766        try:
767            with open(self.snapshot_cache_path(), 'r') as stream:
768                cache = camel.load(stream.read())
769        except:
770            pass
771        if current_hash in cache:
772            return True
773        time = time_ns()
774        cache[current_hash] = time
775        if not self.save(self.base_path('snapshots', f'{time}.{self.ext()}')):
776            return False
777        with open(self.snapshot_cache_path(), 'w') as stream:
778            stream.write(camel.dump(cache))
779        return True

This function creates a snapshot of the current database state.

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

Parameters: None

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

def snapshots( self, hide_missing: bool = True, verified_hash_only: bool = False) -> dict[int, tuple[str, str, bool]]:
781    def snapshots(self, hide_missing: bool = True, verified_hash_only: bool = False) \
782            -> dict[int, tuple[str, str, bool]]:
783        """
784        Retrieve a dictionary of snapshots, with their respective hashes, paths, and existence status.
785
786        Parameters:
787        - hide_missing (bool): If True, only include snapshots that exist in the dictionary. Default is True.
788        - verified_hash_only (bool): If True, only include snapshots with a valid hash. Default is False.
789
790        Returns:
791        - dict[int, tuple[str, str, bool]]: A dictionary where the keys are the timestamps of the snapshots,
792        and the values are tuples containing the snapshot's hash, path, and existence status.
793        """
794        cache: dict[str, int] = {}  # hash: time_ns
795        try:
796            with open(self.snapshot_cache_path(), 'r') as stream:
797                cache = camel.load(stream.read())
798        except:
799            pass
800        if not cache:
801            return {}
802        result: dict[int, tuple[str, str, bool]] = {}  # time_ns: (hash, path, exists)
803        for file_hash, ref in cache.items():
804            path = self.base_path('snapshots', f'{ref}.{self.ext()}')
805            exists = os.path.exists(path)
806            valid_hash = self.file_hash(path) == file_hash if verified_hash_only else True
807            if (verified_hash_only and not valid_hash) or (verified_hash_only and not exists):
808                continue
809            if exists or not hide_missing:
810                result[ref] = (file_hash, path, exists)
811        return result

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

Parameters:

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

Returns:

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

Revert the last operation.

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

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

def ref_exists(self, account: str, ref_type: str, ref: int) -> bool:
961    def ref_exists(self, account: str, ref_type: str, ref: int) -> bool:
962        """
963        Check if a specific reference (transaction) exists in the vault for a given account and reference type.
964
965        Parameters:
966        account (str): The account number for which to check the existence of the reference.
967        ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
968        ref (int): The reference (transaction) number to check for existence.
969
970        Returns:
971        bool: True if the reference exists for the given account and reference type, False otherwise.
972        """
973        if account in self._vault['account']:
974            return ref in self._vault['account'][account][ref_type]
975        return False

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

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

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

def box_exists(self, account: str, ref: int) -> bool:
977    def box_exists(self, account: str, ref: int) -> bool:
978        """
979        Check if a specific box (transaction) exists in the vault for a given account and reference.
980
981        Parameters:
982        - account (str): The account number for which to check the existence of the box.
983        - ref (int): The reference (transaction) number to check for existence.
984
985        Returns:
986        - bool: True if the box exists for the given account and reference, False otherwise.
987        """
988        return self.ref_exists(account, 'box', ref)

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

Parameters:

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

Returns:

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

This function tracks a transaction for a specific account.

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

Returns: int: The timestamp of the transaction.

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

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

def log_exists(self, account: str, ref: int) -> bool:
1056    def log_exists(self, account: str, ref: int) -> bool:
1057        """
1058        Checks if a specific transaction log entry exists for a given account.
1059
1060        Parameters:
1061        account (str): The account number associated with the transaction log.
1062        ref (int): The reference to the transaction log entry.
1063
1064        Returns:
1065        bool: True if the transaction log entry exists, False otherwise.
1066        """
1067        return self.ref_exists(account, 'log', ref)

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

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

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

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

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

Parameters:

  • account (str): The account number for which the exchange rate is being recorded or retrieved.
  • created (int): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
  • rate (float): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
  • description (str): A description of the exchange rate.
  • debug (bool): Whether to print debug information. Default is False.

Returns:

  • dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found, it returns a dictionary with default values for the rate and description.
@staticmethod
def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
1167    @staticmethod
1168    def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
1169        """
1170        This function calculates the exchanged amount of a currency.
1171
1172        Args:
1173            x (float): The original amount of the currency.
1174            x_rate (float): The exchange rate of the original currency.
1175            y_rate (float): The exchange rate of the target currency.
1176
1177        Returns:
1178            float: The exchanged amount of the target currency.
1179        """
1180        return (x * x_rate) / y_rate

This function calculates the exchanged amount of a currency.

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

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

def exchanges(self) -> dict:
1182    def exchanges(self) -> dict:
1183        """
1184        Retrieve the recorded exchange rates for all accounts.
1185
1186        Parameters:
1187        None
1188
1189        Returns:
1190        dict: A dictionary containing all recorded exchange rates.
1191        The keys are account names or numbers, and the values are dictionaries containing the exchange rates.
1192        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
1193        """
1194        return self._vault['exchange'].copy()

Retrieve the recorded exchange rates for all accounts.

Parameters: None

Returns: dict: A dictionary containing all recorded exchange rates. The keys are account names or numbers, and the values are dictionaries containing the exchange rates. Each exchange rate dictionary has timestamps as keys and exchange rate details as values.

def accounts(self) -> dict:
1196    def accounts(self) -> dict:
1197        """
1198        Returns a dictionary containing account numbers as keys and their respective balances as values.
1199
1200        Parameters:
1201        None
1202
1203        Returns:
1204        dict: A dictionary where keys are account numbers and values are their respective balances.
1205        """
1206        result = {}
1207        for i in self._vault['account']:
1208            result[i] = self._vault['account'][i]['balance']
1209        return result

Returns a dictionary containing account numbers as keys and their respective balances as values.

Parameters: None

Returns: dict: A dictionary where keys are account numbers and values are their respective balances.

def boxes(self, account) -> dict:
1211    def boxes(self, account) -> dict:
1212        """
1213        Retrieve the boxes (transactions) associated with a specific account.
1214
1215        Parameters:
1216        account (str): The account number for which to retrieve the boxes.
1217
1218        Returns:
1219        dict: A dictionary containing the boxes associated with the given account.
1220        If the account does not exist, an empty dictionary is returned.
1221        """
1222        if self.account_exists(account):
1223            return self._vault['account'][account]['box']
1224        return {}

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

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

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

def logs(self, account) -> dict:
1226    def logs(self, account) -> dict:
1227        """
1228        Retrieve the logs (transactions) associated with a specific account.
1229
1230        Parameters:
1231        account (str): The account number for which to retrieve the logs.
1232
1233        Returns:
1234        dict: A dictionary containing the logs associated with the given account.
1235        If the account does not exist, an empty dictionary is returned.
1236        """
1237        if self.account_exists(account):
1238            return self._vault['account'][account]['log']
1239        return {}

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

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

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

def daily_logs_init(self) -> dict[str, dict]:
1241    def daily_logs_init(self) -> dict[str, dict]:
1242        """
1243        Initialize a dictionary to store daily, weekly, monthly, and yearly logs.
1244
1245        Returns:
1246        dict: A dictionary with keys 'daily', 'weekly', 'monthly', and 'yearly', each containing an empty dictionary.
1247            Later each key maps to another dictionary, which will store the logs for the corresponding time period.
1248        """
1249        return {
1250            'daily': {},
1251            'weekly': {},
1252            'monthly': {},
1253            'yearly': {},
1254        }

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