zakat
xxx

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

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

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

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

Initialize ZakatTracker with database path and history mode.

Parameters:

  • db_path (str): The path to the database directory. Default is "./zakat_db".
  • history_mode (bool): The mode for tracking history. Default is True.

Returns: None

@staticmethod
def Version() -> str:
285    @staticmethod
286    def Version() -> str:
287        """
288        Returns the current version of the software.
289
290        This function returns a string representing the current version of the software,
291        including major, minor, and patch version numbers in the format "X.Y.Z".
292
293        Returns:
294        - str: The current version of the software.
295        """
296        return '0.2.98'

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:
298    @staticmethod
299    def ZakatCut(x: float) -> float:
300        """
301        Calculates the Zakat amount due on an asset.
302
303        This function calculates the zakat amount due on a given asset value over one lunar year.
304        Zakat is an Islamic obligatory alms-giving, calculated as a fixed percentage of an individual's wealth
305        that exceeds a certain threshold (Nisab).
306
307        Parameters:
308        - x (float): The total value of the asset on which Zakat is to be calculated.
309
310        Returns:
311        - float: The amount of Zakat due on the asset, calculated as 2.5% of the asset's value.
312        """
313        return 0.025 * x  # Zakat Cut in one Lunar Year

Calculates the Zakat amount due on an asset.

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

Parameters:

  • x (float): The total value of the asset on which Zakat is to be calculated.

Returns:

  • float: The amount of Zakat due on the asset, calculated as 2.5% of the asset's value.
@staticmethod
def TimeCycle(days: int = 355) -> int:
315    @staticmethod
316    def TimeCycle(days: int = 355) -> int:
317        """
318        Calculates the approximate duration of a lunar year in nanoseconds.
319
320        This function calculates the approximate duration of a lunar year based on the given number of days.
321        It converts the given number of days into nanoseconds for use in high-precision timing applications.
322
323        Parameters:
324        - days (int): The number of days in a lunar year. Defaults to 355,
325              which is an approximation of the average length of a lunar year.
326
327        Returns:
328        - int: The approximate duration of a lunar year in nanoseconds.
329        """
330        return int(60 * 60 * 24 * days * 1e9)  # Lunar Year in nanoseconds

Calculates the approximate duration of a lunar year in nanoseconds.

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

Parameters:

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

Returns:

  • int: The approximate duration of a lunar year in nanoseconds.
@staticmethod
def Nisab(gram_price: float, gram_quantity: float = 595) -> float:
332    @staticmethod
333    def Nisab(gram_price: float, gram_quantity: float = 595) -> float:
334        """
335        Calculate the total value of Nisab (a unit of weight in Islamic jurisprudence) based on the given price per gram.
336
337        This function calculates the Nisab value, which is the minimum threshold of wealth,
338        that makes an individual liable for paying Zakat.
339        The Nisab value is determined by the equivalent value of a specific amount
340        of gold or silver (currently 595 grams in silver) in the local currency.
341
342        Parameters:
343        - gram_price (float): The price per gram of Nisab.
344        - gram_quantity (float): The quantity of grams in a Nisab. Default is 595 grams of silver.
345
346        Returns:
347        - float: The total value of Nisab based on the given price per gram.
348        """
349        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:
351    @staticmethod
352    def ext() -> str:
353        """
354        Returns the file extension used by the ZakatTracker class.
355
356        Returns:
357        - str: The file extension used by the ZakatTracker class, which is 'camel'.
358        """
359        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:
379    def path(self, path: str = None) -> str:
380        """
381        Set or get the path to the database file.
382
383        If no path is provided, the current path is returned.
384        If a path is provided, it is set as the new path.
385        The function also creates the necessary directories if the provided path is a file.
386
387        Parameters:
388        - path (str): The new path to the database file. If not provided, the current path is returned.
389
390        Returns:
391        - str: The current or new path to the database file.
392        """
393        if path is None:
394            return self._vault_path
395        self._vault_path = pathlib.Path(path).resolve()
396        base_path = pathlib.Path(path).resolve()
397        if base_path.is_file() or base_path.suffix:
398            base_path = base_path.parent
399        base_path.mkdir(parents=True, exist_ok=True)
400        self._base_path = base_path
401        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:
403    def base_path(self, *args) -> str:
404        """
405        Generate a base path by joining the provided arguments with the existing base path.
406
407        Parameters:
408        - *args (str): Variable length argument list of strings to be joined with the base path.
409
410        Returns:
411        - str: The generated base path. If no arguments are provided, the existing base path is returned.
412        """
413        if not args:
414            return str(self._base_path)
415        filtered_args = []
416        ignored_filename = None
417        for arg in args:
418            if pathlib.Path(arg).suffix:
419                ignored_filename = arg
420            else:
421                filtered_args.append(arg)
422        base_path = pathlib.Path(self._base_path)
423        full_path = base_path.joinpath(*filtered_args)
424        full_path.mkdir(parents=True, exist_ok=True)
425        if ignored_filename is not None:
426            return full_path.resolve() / ignored_filename  # Join with the ignored filename
427        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:
429    @staticmethod
430    def scale(x: float | int | decimal.Decimal, decimal_places: int = 2) -> int:
431        """
432        Scales a numerical value by a specified power of 10, returning an integer.
433
434        This function is designed to handle various numeric types (`float`, `int`, or `decimal.Decimal`) and
435        facilitate precise scaling operations, particularly useful in financial or scientific calculations.
436
437        Parameters:
438        - x (float | int | decimal.Decimal): The numeric value to scale. Can be a floating-point number, integer, or decimal.
439        - decimal_places (int): The exponent for the scaling factor (10**y). Defaults to 2, meaning the input is scaled
440            by a factor of 100 (e.g., converts 1.23 to 123).
441
442        Returns:
443        - The scaled value, rounded to the nearest integer.
444
445        Raises:
446        - TypeError: If the input `x` is not a valid numeric type.
447
448        Examples:
449        >>> ZakatTracker.scale(3.14159)
450        314
451        >>> ZakatTracker.scale(1234, decimal_places=3)
452        1234000
453        >>> ZakatTracker.scale(Decimal("0.005"), decimal_places=4)
454        50
455        """
456        if not isinstance(x, (float, int, decimal.Decimal)):
457            raise TypeError("Input 'x' must be a float, int, or decimal.Decimal.")
458        return int(decimal.Decimal(f"{x:.{decimal_places}f}") * (10 ** decimal_places))

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

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

Parameters:

  • x (float | int | decimal.Decimal): The numeric value to scale. Can be a floating-point number, integer, or decimal.
  • decimal_places (int): 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:
460    @staticmethod
461    def unscale(x: int, return_type: type = float, decimal_places: int = 2) -> float | decimal.Decimal:
462        """
463        Unscales an integer by a power of 10.
464
465        Parameters:
466        - x (int): The integer to unscale.
467        - return_type (type): The desired type for the returned value. Can be float, int, or decimal.Decimal. Defaults to float.
468        - decimal_places (int): The power of 10 to use. Defaults to 2.
469
470        Returns:
471        - float | int | decimal.Decimal: The unscaled number, converted to the specified return_type.
472
473        Raises:
474        - TypeError: If the return_type is not float or decimal.Decimal.
475        """
476        if return_type not in (float, decimal.Decimal):
477            raise TypeError(f'Invalid return_type({return_type}). Supported types are float, int, and decimal.Decimal.')
478        return round(return_type(x / (10 ** decimal_places)), decimal_places)

Unscales an integer by a power of 10.

Parameters:

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

Returns:

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

Raises:

  • TypeError: If the return_type is not float or decimal.Decimal.
def reset(self) -> None:
480    def reset(self) -> None:
481        """
482        Reset the internal data structure to its initial state.
483
484        Parameters:
485        None
486
487        Returns:
488        None
489        """
490        self._vault = {
491            'account': {},
492            'exchange': {},
493            'history': {},
494            'lock': None,
495            'report': {},
496        }

Reset the internal data structure to its initial state.

Parameters: None

Returns: None

@staticmethod
def minimum_time_diff_ns() -> tuple[int, int]:
501    @staticmethod
502    def minimum_time_diff_ns() -> tuple[int, int]:
503        """
504        Calculates the minimum time difference between two consecutive calls to
505        `ZakatTracker._time()` in nanoseconds.
506
507        This method is used internally to determine the minimum granularity of
508        time measurements within the system.
509
510        Returns:
511        tuple[int, int]:
512            - The minimum time difference in nanoseconds.
513            - The number of iterations required to measure the difference.
514        """
515        i = 0
516        x = y = ZakatTracker._time()
517        while x == y:
518            y = ZakatTracker._time()
519            i += 1
520        return y - x, i

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

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

Returns: tuple[int, int]: - The minimum time difference in nanoseconds. - The number of iterations required to measure the difference.

@staticmethod
def time(now: datetime.datetime = None) -> int:
539    @staticmethod
540    def time(now: datetime.datetime = None) -> int:
541        """
542        Generates a unique, monotonically increasing timestamp based on the provided
543        datetime object or the current datetime.
544
545        This method ensures that timestamps are unique even if called in rapid succession
546        by introducing a small delay if necessary, based on the system's minimum
547        time resolution.
548
549        Parameters:
550        - now (datetime.datetime, optional): The datetime object to generate the timestamp from.
551        If not provided, the current datetime is used.
552
553        Returns:
554        - int: The unique timestamp in nanoseconds since the epoch (January 1, 1AD).
555        """
556        new_time = ZakatTracker._time(now)
557        if ZakatTracker._last_time_ns is None:
558            ZakatTracker._last_time_ns = new_time
559            return new_time
560        while new_time == ZakatTracker._last_time_ns:
561            if ZakatTracker._time_diff_ns is None:
562                diff, _ = ZakatTracker.minimum_time_diff_ns()
563                ZakatTracker._time_diff_ns = math.ceil(diff)
564            time.sleep(ZakatTracker._time_diff_ns / 1_000_000_000)
565            new_time = ZakatTracker._time()
566        ZakatTracker._last_time_ns = new_time
567        return new_time

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

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

Parameters:

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

Returns:

  • int: The unique timestamp in nanoseconds since the epoch (January 1, 1AD).
@staticmethod
def time_to_datetime(ordinal_ns: int) -> datetime.datetime:
569    @staticmethod
570    def time_to_datetime(ordinal_ns: int) -> datetime.datetime:
571        """
572        Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD)
573        back to a datetime object.
574
575        Parameters:
576        - ordinal_ns (int): The timestamp in nanoseconds since the epoch (January 1, 1AD).
577
578        Returns:
579        - datetime.datetime: The corresponding datetime object.
580        """
581        d = datetime.datetime.fromordinal(ordinal_ns // 86_400_000_000_000)
582        t = datetime.timedelta(seconds=(ordinal_ns % 86_400_000_000_000) // 10 ** 9)
583        return datetime.datetime.combine(d, datetime.time()) + t

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

Parameters:

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

Returns:

  • datetime.datetime: The corresponding datetime object.
def clean_history(self, lock: int | None = None) -> int:
585    def clean_history(self, lock: int | None = None) -> int:
586        """
587        Cleans up the empty history records of actions performed on the ZakatTracker instance.
588
589        Parameters:
590        - lock (int, optional): The lock ID is used to clean up the empty history.
591            If not provided, it cleans up the empty history records for all locks.
592
593        Returns:
594        - int: The number of locks cleaned up.
595        """
596        count = 0
597        if lock in self._vault['history']:
598            if len(self._vault['history'][lock]) <= 0:
599                count += 1
600                del self._vault['history'][lock]
601            return count
602        self.free(self.lock())
603        for lock in self._vault['history']:
604            if len(self._vault['history'][lock]) <= 0:
605                count += 1
606                del self._vault['history'][lock]
607        return count

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

Parameters:

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

Returns:

  • int: The number of locks cleaned up.
def nolock(self) -> bool:
663    def nolock(self) -> bool:
664        """
665        Check if the vault lock is currently not set.
666
667        Returns:
668        - bool: True if the vault lock is not set, False otherwise.
669        """
670        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:
672    def lock(self) -> int:
673        """
674        Acquires a lock on the ZakatTracker instance.
675
676        Returns:
677        - int: The lock ID. This ID can be used to release the lock later.
678        """
679        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 steps(self) -> dict:
681    def steps(self) -> dict:
682        """
683        Returns a copy of the history of steps taken in the ZakatTracker.
684
685        The history is a dictionary where each key is a unique identifier for a step,
686        and the corresponding value is a dictionary containing information about the step.
687
688        Returns:
689        - dict: A copy of the history of steps taken in the ZakatTracker.
690        """
691        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:
693    def free(self, lock: int, auto_save: bool = True) -> bool:
694        """
695        Releases the lock on the database.
696
697        Parameters:
698        - lock (int): The lock ID to be released.
699        - auto_save (bool): Whether to automatically save the database after releasing the lock.
700
701        Returns:
702        - bool: True if the lock is successfully released and (optionally) saved, False otherwise.
703        """
704        if lock == self._vault['lock']:
705            self._vault['lock'] = None
706            self.clean_history(lock)
707            if auto_save:
708                return self.save(self.path())
709            return True
710        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 recall(self, dry: bool = True, debug: bool = False) -> bool:
712    def recall(self, dry: bool = True, debug: bool = False) -> bool:
713        """
714        Revert the last operation.
715
716        Parameters:
717        - dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
718        - debug (bool): If True, the function will print debug information. Default is False.
719
720        Returns:
721        - bool: True if the operation was successful, False otherwise.
722        """
723        if not self.nolock() or len(self._vault['history']) == 0:
724            return False
725        if len(self._vault['history']) <= 0:
726            return False
727        ref = sorted(self._vault['history'].keys())[-1]
728        if debug:
729            print('recall', ref)
730        memory = self._vault['history'][ref]
731        if debug:
732            print(type(memory), 'memory', memory)
733        limit = len(memory) + 1
734        sub_positive_log_negative = 0
735        for i in range(-1, -limit, -1):
736            x = memory[i]
737            if debug:
738                print(type(x), x)
739            match x['action']:
740                case Action.CREATE:
741                    if x['account'] is not None:
742                        if self.account_exists(x['account']):
743                            if debug:
744                                print('account', self._vault['account'][x['account']])
745                            assert len(self._vault['account'][x['account']]['box']) == 0
746                            assert self._vault['account'][x['account']]['balance'] == 0
747                            assert self._vault['account'][x['account']]['count'] == 0
748                            if dry:
749                                continue
750                            del self._vault['account'][x['account']]
751
752                case Action.TRACK:
753                    if x['account'] is not None:
754                        if self.account_exists(x['account']):
755                            if dry:
756                                continue
757                            self._vault['account'][x['account']]['balance'] -= x['value']
758                            self._vault['account'][x['account']]['count'] -= 1
759                            del self._vault['account'][x['account']]['box'][x['ref']]
760
761                case Action.LOG:
762                    if x['account'] is not None:
763                        if self.account_exists(x['account']):
764                            if x['ref'] in self._vault['account'][x['account']]['log']:
765                                if dry:
766                                    continue
767                                if sub_positive_log_negative == -x['value']:
768                                    self._vault['account'][x['account']]['count'] -= 1
769                                    sub_positive_log_negative = 0
770                                box_ref = self._vault['account'][x['account']]['log'][x['ref']]['ref']
771                                if not box_ref is None:
772                                    assert self.box_exists(x['account'], box_ref)
773                                    box_value = self._vault['account'][x['account']]['log'][x['ref']]['value']
774                                    assert box_value < 0
775
776                                    try:
777                                        self._vault['account'][x['account']]['box'][box_ref]['rest'] += -box_value
778                                    except TypeError:
779                                        self._vault['account'][x['account']]['box'][box_ref]['rest'] += decimal.Decimal(
780                                            -box_value)
781
782                                    try:
783                                        self._vault['account'][x['account']]['balance'] += -box_value
784                                    except TypeError:
785                                        self._vault['account'][x['account']]['balance'] += decimal.Decimal(-box_value)
786
787                                    self._vault['account'][x['account']]['count'] -= 1
788                                del self._vault['account'][x['account']]['log'][x['ref']]
789
790                case Action.SUB:
791                    if x['account'] is not None:
792                        if self.account_exists(x['account']):
793                            if x['ref'] in self._vault['account'][x['account']]['box']:
794                                if dry:
795                                    continue
796                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] += x['value']
797                                self._vault['account'][x['account']]['balance'] += x['value']
798                                sub_positive_log_negative = x['value']
799
800                case Action.ADD_FILE:
801                    if x['account'] is not None:
802                        if self.account_exists(x['account']):
803                            if x['ref'] in self._vault['account'][x['account']]['log']:
804                                if x['file'] in self._vault['account'][x['account']]['log'][x['ref']]['file']:
805                                    if dry:
806                                        continue
807                                    del self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']]
808
809                case Action.REMOVE_FILE:
810                    if x['account'] is not None:
811                        if self.account_exists(x['account']):
812                            if x['ref'] in self._vault['account'][x['account']]['log']:
813                                if dry:
814                                    continue
815                                self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']] = x['value']
816
817                case Action.BOX_TRANSFER:
818                    if x['account'] is not None:
819                        if self.account_exists(x['account']):
820                            if x['ref'] in self._vault['account'][x['account']]['box']:
821                                if dry:
822                                    continue
823                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] -= x['value']
824
825                case Action.EXCHANGE:
826                    if x['account'] is not None:
827                        if x['account'] in self._vault['exchange']:
828                            if x['ref'] in self._vault['exchange'][x['account']]:
829                                if dry:
830                                    continue
831                                del self._vault['exchange'][x['account']][x['ref']]
832
833                case Action.REPORT:
834                    if x['ref'] in self._vault['report']:
835                        if dry:
836                            continue
837                        del self._vault['report'][x['ref']]
838
839                case Action.ZAKAT:
840                    if x['account'] is not None:
841                        if self.account_exists(x['account']):
842                            if x['ref'] in self._vault['account'][x['account']]['box']:
843                                if x['key'] in self._vault['account'][x['account']]['box'][x['ref']]:
844                                    if dry:
845                                        continue
846                                    match x['math']:
847                                        case MathOperation.ADDITION:
848                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] -= x[
849                                                'value']
850                                        case MathOperation.EQUAL:
851                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] = x['value']
852                                        case MathOperation.SUBTRACTION:
853                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] += x[
854                                                'value']
855
856        if not dry:
857            del self._vault['history'][ref]
858        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 vault(self) -> dict:
860    def vault(self) -> dict:
861        """
862        Returns a copy of the internal vault dictionary.
863
864        This method is used to retrieve the current state of the ZakatTracker object.
865        It provides a snapshot of the internal data structure, allowing for further
866        processing or analysis.
867
868        Returns:
869        - dict: A copy of the internal vault dictionary.
870        """
871        return self._vault.copy()

Returns a copy of the internal vault dictionary.

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

Returns:

  • dict: A copy of the internal vault dictionary.
def stats_init(self) -> dict[str, tuple[int, str]]:
873    def stats_init(self) -> dict[str, tuple[int, str]]:
874        """
875        Initialize and return a dictionary containing initial statistics for the ZakatTracker instance.
876
877        The dictionary contains two keys: 'database' and 'ram'. Each key maps to a tuple containing two elements:
878        - The initial size of the respective statistic in bytes (int).
879        - The initial size of the respective statistic in a human-readable format (str).
880
881        Returns:
882        - dict[str, tuple]: A dictionary with initial statistics for the ZakatTracker instance.
883        """
884        return {
885            'database': (0, '0'),
886            'ram': (0, '0'),
887        }

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

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

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

Returns:

  • dict[str, tuple]: A dictionary with initial statistics for the ZakatTracker instance.
def stats(self, ignore_ram: bool = True) -> dict[str, tuple[int, str]]:
889    def stats(self, ignore_ram: bool = True) -> dict[str, tuple[int, str]]:
890        """
891        Calculates and returns statistics about the object's data storage.
892
893        This method determines the size of the database file on disk and the
894        size of the data currently held in RAM (likely within a dictionary).
895        Both sizes are reported in bytes and in a human-readable format
896        (e.g., KB, MB).
897
898        Parameters:
899        - ignore_ram (bool): Whether to ignore the RAM size. Default is True
900
901        Returns:
902        - dict[str, tuple]: A dictionary containing the following statistics:
903
904            * 'database': A tuple with two elements:
905                - The database file size in bytes (int).
906                - The database file size in human-readable format (str).
907            * 'ram': A tuple with two elements:
908                - The RAM usage (dictionary size) in bytes (int).
909                - The RAM usage in human-readable format (str).
910
911        Example:
912        >>> stats = my_object.stats()
913        >>> print(stats['database'])
914        (256000, '250.0 KB')
915        >>> print(stats['ram'])
916        (12345, '12.1 KB')
917        """
918        ram_size = 0.0 if ignore_ram else self.get_dict_size(self.vault())
919        file_size = os.path.getsize(self.path())
920        return {
921            'database': (file_size, self.human_readable_size(file_size)),
922            'ram': (ram_size, self.human_readable_size(ram_size)),
923        }

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

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

Parameters:

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

Returns:

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

Example:

>>> stats = my_object.stats()
>>> print(stats['database'])
(256000, '250.0 KB')
>>> print(stats['ram'])
(12345, '12.1 KB')
def files(self) -> list[dict[str, str | int]]:
925    def files(self) -> list[dict[str, str | int]]:
926        """
927        Retrieves information about files associated with this class.
928
929        This class method provides a standardized way to gather details about
930        files used by the class for storage, snapshots, and CSV imports.
931
932        Returns:
933        - list[dict[str, str | int]]: A list of dictionaries, each containing information
934            about a specific file:
935
936            * type (str): The type of file ('database', 'snapshot', 'import_csv').
937            * path (str): The full file path.
938            * exists (bool): Whether the file exists on the filesystem.
939            * size (int): The file size in bytes (0 if the file doesn't exist).
940            * human_readable_size (str): A human-friendly representation of the file size (e.g., '10 KB', '2.5 MB').
941
942        Example:
943        ```
944        file_info = MyClass.files()
945        for info in file_info:
946            print(f"Type: {info['type']}, Exists: {info['exists']}, Size: {info['human_readable_size']}")
947        ```
948        """
949        result = []
950        for file_type, path in {
951            'database': self.path(),
952            'snapshot': self.snapshot_cache_path(),
953            'import_csv': self.import_csv_cache_path(),
954        }.items():
955            exists = os.path.exists(path)
956            size = os.path.getsize(path) if exists else 0
957            human_readable_size = self.human_readable_size(size) if exists else 0
958            result.append({
959                'type': file_type,
960                'path': path,
961                'exists': exists,
962                'size': size,
963                'human_readable_size': human_readable_size,
964            })
965        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 account_exists(self, account) -> bool:
967    def account_exists(self, account) -> bool:
968        """
969        Check if the given account exists in the vault.
970
971        Parameters:
972        - account (str): The account number to check.
973
974        Returns:
975        - bool: True if the account exists, False otherwise.
976        """
977        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:
979    def box_size(self, account) -> int:
980        """
981        Calculate the size of the box for a specific account.
982
983        Parameters:
984        - account (str): The account number for which the box size needs to be calculated.
985
986        Returns:
987        - int: The size of the box for the given account. If the account does not exist, -1 is returned.
988        """
989        if self.account_exists(account):
990            return len(self._vault['account'][account]['box'])
991        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:
 993    def log_size(self, account) -> int:
 994        """
 995        Get the size of the log for a specific account.
 996
 997        Parameters:
 998        - account (str): The account number for which the log size needs to be calculated.
 999
1000        Returns:
1001        - int: The size of the log for the given account. If the account does not exist, -1 is returned.
1002        """
1003        if self.account_exists(account):
1004            return len(self._vault['account'][account]['log'])
1005        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:
1007    @staticmethod
1008    def file_hash(file_path: str, algorithm: str = "blake2b") -> str:
1009        """
1010        Calculates the hash of a file using the specified algorithm.
1011
1012        Parameters:
1013        - file_path (str): The path to the file.
1014        - algorithm (str, optional): The hashing algorithm to use. Defaults to "blake2b".
1015
1016        Returns:
1017        - str: The hexadecimal representation of the file's hash.
1018        """
1019        hash_obj = hashlib.new(algorithm)  # Create the hash object
1020        with open(file_path, "rb") as f:  # Open file in binary mode for reading
1021            for chunk in iter(lambda: f.read(4096), b""):  # Read file in chunks
1022                hash_obj.update(chunk)
1023        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):
1025    def snapshot_cache_path(self):
1026        """
1027        Generate the path for the cache file used to store snapshots.
1028
1029        The cache file is a camel file that stores the timestamps of the snapshots.
1030        The file name is derived from the main database file name by replacing the ".camel" extension with ".snapshots.camel".
1031
1032        Returns:
1033        - str: The path to the cache file.
1034        """
1035        path = str(self.path())
1036        ext = self.ext()
1037        ext_len = len(ext)
1038        if path.endswith(f'.{ext}'):
1039            path = path[:-ext_len - 1]
1040        _, filename = os.path.split(path + f'.snapshots.{ext}')
1041        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:
1043    def snapshot(self) -> bool:
1044        """
1045        This function creates a snapshot of the current database state.
1046
1047        The function calculates the hash of the current database file and checks if a snapshot with the same hash already exists.
1048        If a snapshot with the same hash exists, the function returns True without creating a new snapshot.
1049        If a snapshot with the same hash does not exist, the function creates a new snapshot by saving the current database state
1050        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.
1051
1052        Parameters:
1053        None
1054
1055        Returns:
1056        - bool: True if a snapshot with the same hash already exists or if the snapshot is successfully created. False if the snapshot creation fails.
1057        """
1058        current_hash = self.file_hash(self.path())
1059        cache: dict[str, int] = {}  # hash: time_ns
1060        try:
1061            with open(self.snapshot_cache_path(), 'r', encoding="utf-8") as stream:
1062                cache = camel.load(stream.read())
1063        except:
1064            pass
1065        if current_hash in cache:
1066            return True
1067        ref = time.time_ns()
1068        cache[current_hash] = ref
1069        if not self.save(self.base_path('snapshots', f'{ref}.{self.ext()}')):
1070            return False
1071        with open(self.snapshot_cache_path(), 'w', encoding="utf-8") as stream:
1072            stream.write(camel.dump(cache))
1073        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]]:
1075    def snapshots(self, hide_missing: bool = True, verified_hash_only: bool = False) \
1076            -> dict[int, tuple[str, str, bool]]:
1077        """
1078        Retrieve a dictionary of snapshots, with their respective hashes, paths, and existence status.
1079
1080        Parameters:
1081        - hide_missing (bool): If True, only include snapshots that exist in the dictionary. Default is True.
1082        - verified_hash_only (bool): If True, only include snapshots with a valid hash. Default is False.
1083
1084        Returns:
1085        - dict[int, tuple[str, str, bool]]: A dictionary where the keys are the timestamps of the snapshots,
1086        and the values are tuples containing the snapshot's hash, path, and existence status.
1087        """
1088        cache: dict[str, int] = {}  # hash: time_ns
1089        try:
1090            with open(self.snapshot_cache_path(), 'r', encoding="utf-8") as stream:
1091                cache = camel.load(stream.read())
1092        except:
1093            pass
1094        if not cache:
1095            return {}
1096        result: dict[int, tuple[str, str, bool]] = {}  # time_ns: (hash, path, exists)
1097        for file_hash, ref in cache.items():
1098            path = self.base_path('snapshots', f'{ref}.{self.ext()}')
1099            exists = os.path.exists(path)
1100            valid_hash = self.file_hash(path) == file_hash if verified_hash_only else True
1101            if (verified_hash_only and not valid_hash) or (verified_hash_only and not exists):
1102                continue
1103            if exists or not hide_missing:
1104                result[ref] = (file_hash, path, exists)
1105        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 ref_exists(self, account: str, ref_type: str, ref: int) -> bool:
1107    def ref_exists(self, account: str, ref_type: str, ref: int) -> bool:
1108        """
1109        Check if a specific reference (transaction) exists in the vault for a given account and reference type.
1110
1111        Parameters:
1112        - account (str): The account number for which to check the existence of the reference.
1113        - ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
1114        - ref (int): The reference (transaction) number to check for existence.
1115
1116        Returns:
1117        - bool: True if the reference exists for the given account and reference type, False otherwise.
1118        """
1119        if account in self._vault['account']:
1120            return ref in self._vault['account'][account][ref_type]
1121        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:
1123    def box_exists(self, account: str, ref: int) -> bool:
1124        """
1125        Check if a specific box (transaction) exists in the vault for a given account and reference.
1126
1127        Parameters:
1128        - account (str): The account number for which to check the existence of the box.
1129        - ref (int): The reference (transaction) number to check for existence.
1130
1131        Returns:
1132        - bool: True if the box exists for the given account and reference, False otherwise.
1133        """
1134        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:
1136    def track(self, unscaled_value: float | int | decimal.Decimal = 0, desc: str = '', account: str = 1, logging: bool = True,
1137              created: int = None,
1138              debug: bool = False) -> int:
1139        """
1140        This function tracks a transaction for a specific account, so it do creates a new account if it doesn't exist, logs the transaction if logging is True, and updates the account's balance and box.
1141
1142        Parameters:
1143        - unscaled_value (float | int | decimal.Decimal): The value of the transaction. Default is 0.
1144        - desc (str): The description of the transaction. Default is an empty string.
1145        - account (str): The account for which the transaction is being tracked. Default is '1'.
1146        - logging (bool): Whether to log the transaction. Default is True.
1147        - created (int): The timestamp of the transaction. If not provided, it will be generated. Default is None.
1148        - debug (bool): Whether to print debug information. Default is False.
1149
1150        Returns:
1151        - int: The timestamp of the transaction.
1152
1153        Raises:
1154        - ValueError: The created should be greater than zero.
1155        - ValueError: The log transaction happened again in the same nanosecond time.
1156        - ValueError: The box transaction happened again in the same nanosecond time.
1157        """
1158        if debug:
1159            print('track', f'unscaled_value={unscaled_value}, debug={debug}')
1160        if created is None:
1161            created = self.time()
1162        if created <= 0:
1163            raise ValueError("The created should be greater than zero.")
1164        no_lock = self.nolock()
1165        lock = self.lock()
1166        if not self.account_exists(account):
1167            if debug:
1168                print(f"account {account} created")
1169            self._vault['account'][account] = {
1170                'balance': 0,
1171                'box': {},
1172                'count': 0,
1173                'log': {},
1174                'hide': False,
1175                'zakatable': True,
1176                'created': created,
1177            }
1178            self._step(Action.CREATE, account)
1179        if unscaled_value == 0:
1180            if no_lock:
1181                self.free(lock)
1182            return 0
1183        value = self.scale(unscaled_value)
1184        if logging:
1185            self._log(value=value, desc=desc, account=account, created=created, ref=None, debug=debug)
1186        if debug:
1187            print('create-box', created)
1188        if self.box_exists(account, created):
1189            raise ValueError(f"The box transaction happened again in the same nanosecond time({created}).")
1190        if debug:
1191            print('created-box', created)
1192        self._vault['account'][account]['box'][created] = {
1193            'capital': value,
1194            'count': 0,
1195            'last': 0,
1196            'rest': value,
1197            'total': 0,
1198        }
1199        self._step(Action.TRACK, account, ref=created, value=value)
1200        if no_lock:
1201            self.free(lock)
1202        return created

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

Parameters:

  • unscaled_value (float | int | decimal.Decimal): 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.

Raises:

  • ValueError: The created should be greater than zero.
  • ValueError: The log transaction happened again in the same nanosecond time.
  • ValueError: The box transaction happened again in the same nanosecond time.
def log_exists(self, account: str, ref: int) -> bool:
1204    def log_exists(self, account: str, ref: int) -> bool:
1205        """
1206        Checks if a specific transaction log entry exists for a given account.
1207
1208        Parameters:
1209        - account (str): The account number associated with the transaction log.
1210        - ref (int): The reference to the transaction log entry.
1211
1212        Returns:
1213        - bool: True if the transaction log entry exists, False otherwise.
1214        """
1215        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:
1264    def exchange(self, account, created: int = None, rate: float = None, description: str = None,
1265                 debug: bool = False) -> dict:
1266        """
1267        This method is used to record or retrieve exchange rates for a specific account.
1268
1269        Parameters:
1270        - account (str): The account number for which the exchange rate is being recorded or retrieved.
1271        - created (int): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
1272        - rate (float): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
1273        - description (str): A description of the exchange rate.
1274        - debug (bool): Whether to print debug information. Default is False.
1275
1276        Returns:
1277        - dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found,
1278        it returns a dictionary with default values for the rate and description.
1279
1280        Raises:
1281        - ValueError: The created should be greater than zero.
1282        """
1283        if debug:
1284            print('exchange', f'debug={debug}')
1285        if created is None:
1286            created = self.time()
1287        if created <= 0:
1288            raise ValueError("The created should be greater than zero.")
1289        no_lock = self.nolock()
1290        lock = self.lock()
1291        if rate is not None:
1292            if rate <= 0:
1293                return dict()
1294            if account not in self._vault['exchange']:
1295                self._vault['exchange'][account] = {}
1296            if len(self._vault['exchange'][account]) == 0 and rate <= 1:
1297                return {"time": created, "rate": 1, "description": None}
1298            self._vault['exchange'][account][created] = {"rate": rate, "description": description}
1299            self._step(Action.EXCHANGE, account, ref=created, value=rate)
1300            if no_lock:
1301                self.free(lock)
1302            if debug:
1303                print("exchange-created-1",
1304                      f'account: {account}, created: {created}, rate:{rate}, description:{description}')
1305
1306        if account in self._vault['exchange']:
1307            valid_rates = [(ts, r) for ts, r in self._vault['exchange'][account].items() if ts <= created]
1308            if valid_rates:
1309                latest_rate = max(valid_rates, key=lambda x: x[0])
1310                if debug:
1311                    print("exchange-read-1",
1312                          f'account: {account}, created: {created}, rate:{rate}, description:{description}',
1313                          'latest_rate', latest_rate)
1314                result = latest_rate[1]
1315                result['time'] = latest_rate[0]
1316                return result  # إرجاع قاموس يحتوي على المعدل والوصف
1317        if debug:
1318            print("exchange-read-0", f'account: {account}, created: {created}, rate:{rate}, description:{description}')
1319        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.

Raises:

  • ValueError: The created should be greater than zero.
@staticmethod
def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
1321    @staticmethod
1322    def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
1323        """
1324        This function calculates the exchanged amount of a currency.
1325
1326        Parameters:
1327        - x (float): The original amount of the currency.
1328        - x_rate (float): The exchange rate of the original currency.
1329         - y_rate (float): The exchange rate of the target currency.
1330
1331        Returns:
1332        - float: The exchanged amount of the target currency.
1333        """
1334        return (x * x_rate) / y_rate

This function calculates the exchanged amount of a currency.

Parameters:

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

Returns:

  • float: The exchanged amount of the target currency.
def exchanges(self) -> dict:
1336    def exchanges(self) -> dict:
1337        """
1338        Retrieve the recorded exchange rates for all accounts.
1339
1340        Parameters:
1341        None
1342
1343        Returns:
1344        - dict: A dictionary containing all recorded exchange rates.
1345        The keys are account names or numbers, and the values are dictionaries containing the exchange rates.
1346        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
1347        """
1348        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:
1350    def accounts(self) -> dict:
1351        """
1352        Returns a dictionary containing account numbers as keys and their respective balances as values.
1353
1354        Parameters:
1355        None
1356
1357        Returns:
1358        - dict: A dictionary where keys are account numbers and values are their respective balances.
1359        """
1360        result = {}
1361        for i in self._vault['account']:
1362            result[i] = self._vault['account'][i]['balance']
1363        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:
1365    def boxes(self, account) -> dict:
1366        """
1367        Retrieve the boxes (transactions) associated with a specific account.
1368
1369        Parameters:
1370        - account (str): The account number for which to retrieve the boxes.
1371
1372        Returns:
1373        - dict: A dictionary containing the boxes associated with the given account.
1374        If the account does not exist, an empty dictionary is returned.
1375        """
1376        if self.account_exists(account):
1377            return self._vault['account'][account]['box']
1378        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:
1380    def logs(self, account) -> dict:
1381        """
1382        Retrieve the logs (transactions) associated with a specific account.
1383
1384        Parameters:
1385        - account (str): The account number for which to retrieve the logs.
1386
1387        Returns:
1388        - dict: A dictionary containing the logs associated with the given account.
1389        If the account does not exist, an empty dictionary is returned.
1390        """
1391        if self.account_exists(account):
1392            return self._vault['account'][account]['log']
1393        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]:
1395    def daily_logs_init(self) -> dict[str, dict]:
1396        """
1397        Initialize a dictionary to store daily, weekly, monthly, and yearly logs.
1398
1399        Returns:
1400        dict: A dictionary with keys 'daily', 'weekly', 'monthly', and 'yearly', each containing an empty dictionary.
1401            Later each key maps to another dictionary, which will store the logs for the corresponding time period.
1402        """
1403        return {
1404            'daily': {},
1405            'weekly': {},
1406            'monthly': {},
1407            'yearly': {},
1408        }

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):
1410    def daily_logs(self, weekday: WeekDay = WeekDay.Friday, debug: bool = False):
1411        """
1412        Retrieve the daily logs (transactions) from all accounts.
1413
1414        The function groups the logs by day, month, and year, and calculates the total value for each group.
1415        It returns a dictionary where the keys are the timestamps of the daily groups,
1416        and the values are dictionaries containing the total value and the logs for that group.
1417
1418        Parameters:
1419        - weekday (WeekDay): Select the weekday is collected for the week data. Default is WeekDay.Friday.
1420        - debug (bool): Whether to print debug information. Default is False.
1421
1422        Returns:
1423        dict: A dictionary containing the daily logs.
1424
1425        Example:
1426        >>> tracker = ZakatTracker()
1427        >>> tracker.sub(51, 'desc', 'account1')
1428        >>> ref = tracker.track(100, 'desc', 'account2')
1429        >>> tracker.add_file('account2', ref, 'file_0')
1430        >>> tracker.add_file('account2', ref, 'file_1')
1431        >>> tracker.add_file('account2', ref, 'file_2')
1432        >>> tracker.daily_logs()
1433        {
1434            'daily': {
1435                '2024-06-30': {
1436                    'positive': 100,
1437                    'negative': 51,
1438                    'total': 99,
1439                    'rows': [
1440                        {
1441                            'account': 'account1',
1442                            'desc': 'desc',
1443                            'file': {},
1444                            'ref': None,
1445                            'value': -51,
1446                            'time': 1690977015000000000,
1447                            'transfer': False,
1448                        },
1449                        {
1450                            'account': 'account2',
1451                            'desc': 'desc',
1452                            'file': {
1453                                1722919011626770944: 'file_0',
1454                                1722919011626812928: 'file_1',
1455                                1722919011626846976: 'file_2',
1456                            },
1457                            'ref': None,
1458                            'value': 100,
1459                            'time': 1690977015000000000,
1460                            'transfer': False,
1461                        },
1462                    ],
1463                },
1464            },
1465            'weekly': {
1466                datetime: {
1467                    'positive': 100,
1468                    'negative': 51,
1469                    'total': 99,
1470                },
1471            },
1472            'monthly': {
1473                '2024-06': {
1474                    'positive': 100,
1475                    'negative': 51,
1476                    'total': 99,
1477                },
1478            },
1479            'yearly': {
1480                2024: {
1481                    'positive': 100,
1482                    'negative': 51,
1483                    'total': 99,
1484                },
1485            },
1486        }
1487        """
1488        logs = {}
1489        for account in self.accounts():
1490            for k, v in self.logs(account).items():
1491                v['time'] = k
1492                v['account'] = account
1493                if k not in logs:
1494                    logs[k] = []
1495                logs[k].append(v)
1496        if debug:
1497            print('logs', logs)
1498        y = self.daily_logs_init()
1499        for i in sorted(logs, reverse=True):
1500            dt = self.time_to_datetime(i)
1501            daily = f'{dt.year}-{dt.month:02d}-{dt.day:02d}'
1502            weekly = dt - datetime.timedelta(days=weekday.value)
1503            monthly = f'{dt.year}-{dt.month:02d}'
1504            yearly = dt.year
1505            # daily
1506            if daily not in y['daily']:
1507                y['daily'][daily] = {
1508                    'positive': 0,
1509                    'negative': 0,
1510                    'total': 0,
1511                    'rows': [],
1512                }
1513            transfer = len(logs[i]) > 1
1514            if debug:
1515                print('logs[i]', logs[i])
1516            for z in logs[i]:
1517                if debug:
1518                    print('z', z)
1519                # daily
1520                value = z['value']
1521                if value > 0:
1522                    y['daily'][daily]['positive'] += value
1523                else:
1524                    y['daily'][daily]['negative'] += -value
1525                y['daily'][daily]['total'] += value
1526                z['transfer'] = transfer
1527                y['daily'][daily]['rows'].append(z)
1528                # weekly
1529                if weekly not in y['weekly']:
1530                    y['weekly'][weekly] = {
1531                        'positive': 0,
1532                        'negative': 0,
1533                        'total': 0,
1534                    }
1535                if value > 0:
1536                    y['weekly'][weekly]['positive'] += value
1537                else:
1538                    y['weekly'][weekly]['negative'] += -value
1539                y['weekly'][weekly]['total'] += value
1540                # monthly
1541                if monthly not in y['monthly']:
1542                    y['monthly'][monthly] = {
1543                        'positive': 0,
1544                        'negative': 0,
1545                        'total': 0,
1546                    }
1547                if value > 0:
1548                    y['monthly'][monthly]['positive'] += value
1549                else:
1550                    y['monthly'][monthly]['negative'] += -value
1551                y['monthly'][monthly]['total'] += value
1552                # yearly
1553                if yearly not in y['yearly']:
1554                    y['yearly'][yearly] = {
1555                        'positive': 0,
1556                        'negative': 0,
1557                        'total': 0,
1558                    }
1559                if value > 0:
1560                    y['yearly'][yearly]['positive'] += value
1561                else:
1562                    y['yearly'][yearly]['negative'] += -value
1563                y['yearly'][yearly]['total'] += value
1564        if debug:
1565            print('y', y)
1566        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:
1568    def add_file(self, account: str, ref: int, path: str) -> int:
1569        """
1570        Adds a file reference to a specific transaction log entry in the vault.
1571
1572        Parameters:
1573        - account (str): The account number associated with the transaction log.
1574        - ref (int): The reference to the transaction log entry.
1575        - path (str): The path of the file to be added.
1576
1577        Returns:
1578        - int: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
1579        """
1580        if self.account_exists(account):
1581            if ref in self._vault['account'][account]['log']:
1582                file_ref = self.time()
1583                self._vault['account'][account]['log'][ref]['file'][file_ref] = path
1584                no_lock = self.nolock()
1585                lock = self.lock()
1586                self._step(Action.ADD_FILE, account, ref=ref, file=file_ref)
1587                if no_lock:
1588                    self.free(lock)
1589                return file_ref
1590        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:
1592    def remove_file(self, account: str, ref: int, file_ref: int) -> bool:
1593        """
1594        Removes a file reference from a specific transaction log entry in the vault.
1595
1596        Parameters:
1597        - account (str): The account number associated with the transaction log.
1598        - ref (int): The reference to the transaction log entry.
1599        - file_ref (int): The reference of the file to be removed.
1600
1601        Returns:
1602        - bool: True if the file reference is successfully removed, False otherwise.
1603        """
1604        if self.account_exists(account):
1605            if ref in self._vault['account'][account]['log']:
1606                if file_ref in self._vault['account'][account]['log'][ref]['file']:
1607                    x = self._vault['account'][account]['log'][ref]['file'][file_ref]
1608                    del self._vault['account'][account]['log'][ref]['file'][file_ref]
1609                    no_lock = self.nolock()
1610                    lock = self.lock()
1611                    self._step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x)
1612                    if no_lock:
1613                        self.free(lock)
1614                    return True
1615        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:
1617    def balance(self, account: str = 1, cached: bool = True) -> int:
1618        """
1619        Calculate and return the balance of a specific account.
1620
1621        Parameters:
1622        - account (str): The account number. Default is '1'.
1623        - cached (bool): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
1624
1625        Returns:
1626        - int: The balance of the account.
1627
1628        Notes:
1629        - If cached is True, the function returns the cached balance.
1630        - If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
1631        """
1632        if cached:
1633            return self._vault['account'][account]['balance']
1634        x = 0
1635        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.

Notes:

  • If cached is True, the function returns the cached balance.
  • If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
def hide(self, account, status: bool = None) -> bool:
1637    def hide(self, account, status: bool = None) -> bool:
1638        """
1639        Check or set the hide status of a specific account.
1640
1641        Parameters:
1642        - account (str): The account number.
1643        - status (bool, optional): The new hide status. If not provided, the function will return the current status.
1644
1645        Returns:
1646        - bool: The current or updated hide status of the account.
1647
1648        Raises:
1649        None
1650
1651        Example:
1652        >>> tracker = ZakatTracker()
1653        >>> ref = tracker.track(51, 'desc', 'account1')
1654        >>> tracker.hide('account1')  # Set the hide status of 'account1' to True
1655        False
1656        >>> tracker.hide('account1', True)  # Set the hide status of 'account1' to True
1657        True
1658        >>> tracker.hide('account1')  # Get the hide status of 'account1' by default
1659        True
1660        >>> tracker.hide('account1', False)
1661        False
1662        """
1663        if self.account_exists(account):
1664            if status is None:
1665                return self._vault['account'][account]['hide']
1666            self._vault['account'][account]['hide'] = status
1667            return status
1668        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:
1670    def zakatable(self, account, status: bool = None) -> bool:
1671        """
1672        Check or set the zakatable status of a specific account.
1673
1674        Parameters:
1675        - account (str): The account number.
1676        - status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
1677
1678        Returns:
1679        - bool: The current or updated zakatable status of the account.
1680
1681        Raises:
1682        None
1683
1684        Example:
1685        >>> tracker = ZakatTracker()
1686        >>> ref = tracker.track(51, 'desc', 'account1')
1687        >>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
1688        True
1689        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
1690        True
1691        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
1692        True
1693        >>> tracker.zakatable('account1', False)
1694        False
1695        """
1696        if self.account_exists(account):
1697            if status is None:
1698                return self._vault['account'][account]['zakatable']
1699            self._vault['account'][account]['zakatable'] = status
1700            return status
1701        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:
1703    def sub(self, unscaled_value: float | int | decimal.Decimal, desc: str = '', account: str = 1, created: int = None,
1704            debug: bool = False) \
1705            -> tuple[
1706                   int,
1707                   list[
1708                       tuple[int, int],
1709                   ],
1710               ] | tuple:
1711        """
1712        Subtracts a specified value from an account's balance.
1713
1714        Parameters:
1715        - unscaled_value (float | int | decimal.Decimal): The amount to be subtracted.
1716        - desc (str): A description for the transaction. Defaults to an empty string.
1717        - account (str): The account from which the value will be subtracted. Defaults to '1'.
1718        - created (int): The timestamp of the transaction. If not provided, the current timestamp will be used.
1719        - debug (bool): A flag indicating whether to print debug information. Defaults to False.
1720
1721        Returns:
1722        - tuple: A tuple containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
1723
1724        If the amount to subtract is greater than the account's balance,
1725        the remaining amount will be transferred to a new transaction with a negative value.
1726
1727        Raises:
1728        - ValueError: The created should be greater than zero.
1729        - ValueError: The box transaction happened again in the same nanosecond time.
1730        - ValueError: The log transaction happened again in the same nanosecond time.
1731        """
1732        if debug:
1733            print('sub', f'debug={debug}')
1734        if unscaled_value < 0:
1735            return tuple()
1736        if unscaled_value == 0:
1737            ref = self.track(unscaled_value, '', account)
1738            return ref, ref
1739        if created is None:
1740            created = self.time()
1741        if created <= 0:
1742            raise ValueError("The created should be greater than zero.")
1743        no_lock = self.nolock()
1744        lock = self.lock()
1745        self.track(0, '', account)
1746        value = self.scale(unscaled_value)
1747        self._log(value=-value, desc=desc, account=account, created=created, ref=None, debug=debug)
1748        ids = sorted(self._vault['account'][account]['box'].keys())
1749        limit = len(ids) + 1
1750        target = value
1751        if debug:
1752            print('ids', ids)
1753        ages = []
1754        for i in range(-1, -limit, -1):
1755            if target == 0:
1756                break
1757            j = ids[i]
1758            if debug:
1759                print('i', i, 'j', j)
1760            rest = self._vault['account'][account]['box'][j]['rest']
1761            if rest >= target:
1762                self._vault['account'][account]['box'][j]['rest'] -= target
1763                self._step(Action.SUB, account, ref=j, value=target)
1764                ages.append((j, target))
1765                target = 0
1766                break
1767            elif target > rest > 0:
1768                chunk = rest
1769                target -= chunk
1770                self._step(Action.SUB, account, ref=j, value=chunk)
1771                ages.append((j, chunk))
1772                self._vault['account'][account]['box'][j]['rest'] = 0
1773        if target > 0:
1774            self.track(
1775                unscaled_value=self.unscale(-target),
1776                desc=desc,
1777                account=account,
1778                logging=False,
1779                created=created,
1780            )
1781            ages.append((created, target))
1782        if no_lock:
1783            self.free(lock)
1784        return created, ages

Subtracts a specified value from an account's balance.

Parameters:

  • unscaled_value (float | int | decimal.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 created should be greater than zero.
  • ValueError: The box transaction happened again in the same nanosecond time.
  • ValueError: The log transaction happened again in the same nanosecond time.
def transfer( self, unscaled_amount: float | int | decimal.Decimal, from_account: str, to_account: str, desc: str = '', created: int = None, debug: bool = False) -> list[int]:
1786    def transfer(self, unscaled_amount: float | int | decimal.Decimal, from_account: str, to_account: str, desc: str = '',
1787                 created: int = None,
1788                 debug: bool = False) -> list[int]:
1789        """
1790        Transfers a specified value from one account to another.
1791
1792        Parameters:
1793        - unscaled_amount (float | int | decimal.Decimal): The amount to be transferred.
1794        - from_account (str): The account from which the value will be transferred.
1795        - to_account (str): The account to which the value will be transferred.
1796        - desc (str, optional): A description for the transaction. Defaults to an empty string.
1797        -;created (int, optional): The timestamp of the transaction. If not provided, the current timestamp will be used.
1798        - debug (bool): A flag indicating whether to print debug information. Defaults to False.
1799
1800        Returns:
1801        - list[int]: A list of timestamps corresponding to the transactions made during the transfer.
1802
1803        Raises:
1804        - ValueError: Transfer to the same account is forbidden.
1805        - ValueError: The created should be greater than zero.
1806        - ValueError: The box transaction happened again in the same nanosecond time.
1807        - ValueError: The log transaction happened again in the same nanosecond time.
1808        """
1809        if debug:
1810            print('transfer', f'debug={debug}')
1811        if from_account == to_account:
1812            raise ValueError(f'Transfer to the same account is forbidden. {to_account}')
1813        if unscaled_amount <= 0:
1814            return []
1815        if created is None:
1816            created = self.time()
1817        if created <= 0:
1818            raise ValueError("The created should be greater than zero.")
1819        no_lock = self.nolock()
1820        lock = self.lock()
1821        (_, ages) = self.sub(unscaled_amount, desc, from_account, created, debug=debug)
1822        times = []
1823        source_exchange = self.exchange(from_account, created)
1824        target_exchange = self.exchange(to_account, created)
1825
1826        if debug:
1827            print('ages', ages)
1828
1829        for age, value in ages:
1830            target_amount = int(self.exchange_calc(value, source_exchange['rate'], target_exchange['rate']))
1831            if debug:
1832                print('target_amount', target_amount)
1833            # Perform the transfer
1834            if self.box_exists(to_account, age):
1835                if debug:
1836                    print('box_exists', age)
1837                capital = self._vault['account'][to_account]['box'][age]['capital']
1838                rest = self._vault['account'][to_account]['box'][age]['rest']
1839                if debug:
1840                    print(
1841                        f"Transfer(loop) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1842                selected_age = age
1843                if rest + target_amount > capital:
1844                    self._vault['account'][to_account]['box'][age]['capital'] += target_amount
1845                    selected_age = ZakatTracker.time()
1846                self._vault['account'][to_account]['box'][age]['rest'] += target_amount
1847                self._step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount)
1848                y = self._log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account,
1849                              created=None, ref=None, debug=debug)
1850                times.append((age, y))
1851                continue
1852            if debug:
1853                print(
1854                    f"Transfer(func) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1855            y = self.track(
1856                unscaled_value=self.unscale(int(target_amount)),
1857                desc=desc,
1858                account=to_account,
1859                logging=True,
1860                created=age,
1861                debug=debug,
1862            )
1863            times.append(y)
1864        if no_lock:
1865            self.free(lock)
1866        return times

Transfers a specified value from one account to another.

Parameters:

  • unscaled_amount (float | int | decimal.Decimal): The amount to be transferred.
  • from_account (str): The account from which the value will be transferred.
  • to_account (str): The account to which the value will be transferred.
  • desc (str, optional): A description for the transaction. Defaults to an empty string. -;created (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 created should be greater than zero.
  • ValueError: The box transaction happened again in the same nanosecond time.
  • ValueError: The log transaction happened again in the same nanosecond time.
def check( self, silver_gram_price: float, unscaled_nisab: float | int | decimal.Decimal = None, debug: bool = False, now: int = None, cycle: float = None) -> tuple:
1868    def check(self,
1869              silver_gram_price: float,
1870              unscaled_nisab: float | int | decimal.Decimal = None,
1871              debug: bool = False,
1872              now: int = None,
1873              cycle: float = None) -> tuple:
1874        """
1875        Check the eligibility for Zakat based on the given parameters.
1876
1877        Parameters:
1878        - silver_gram_price (float): The price of a gram of silver.
1879        - unscaled_nisab (float | int | decimal.Decimal): The minimum amount of wealth required for Zakat. If not provided,
1880                        it will be calculated based on the silver_gram_price.
1881        - debug (bool): Flag to enable debug mode.
1882        - now (int): The current timestamp. If not provided, it will be calculated using ZakatTracker.time().
1883        - cycle (float): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
1884
1885        Returns:
1886        - tuple: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics,
1887        and a dictionary containing the Zakat plan.
1888        """
1889        if debug:
1890            print('check', f'debug={debug}')
1891        if now is None:
1892            now = self.time()
1893        if cycle is None:
1894            cycle = ZakatTracker.TimeCycle()
1895        if unscaled_nisab is None:
1896            unscaled_nisab = ZakatTracker.Nisab(silver_gram_price)
1897        nisab = self.scale(unscaled_nisab)
1898        plan = {}
1899        below_nisab = 0
1900        brief = [0, 0, 0]
1901        valid = False
1902        if debug:
1903            print('exchanges', self.exchanges())
1904        for x in self._vault['account']:
1905            if not self.zakatable(x):
1906                continue
1907            _box = self._vault['account'][x]['box']
1908            _log = self._vault['account'][x]['log']
1909            limit = len(_box) + 1
1910            ids = sorted(self._vault['account'][x]['box'].keys())
1911            for i in range(-1, -limit, -1):
1912                j = ids[i]
1913                rest = float(_box[j]['rest'])
1914                if rest <= 0:
1915                    continue
1916                exchange = self.exchange(x, created=self.time())
1917                rest = ZakatTracker.exchange_calc(rest, float(exchange['rate']), 1)
1918                brief[0] += rest
1919                index = limit + i - 1
1920                epoch = (now - j) / cycle
1921                if debug:
1922                    print(f"Epoch: {epoch}", _box[j])
1923                if _box[j]['last'] > 0:
1924                    epoch = (now - _box[j]['last']) / cycle
1925                if debug:
1926                    print(f"Epoch: {epoch}")
1927                epoch = math.floor(epoch)
1928                if debug:
1929                    print(f"Epoch: {epoch}", type(epoch), epoch == 0, 1 - epoch, epoch)
1930                if epoch == 0:
1931                    continue
1932                if debug:
1933                    print("Epoch - PASSED")
1934                brief[1] += rest
1935                if rest >= nisab:
1936                    total = 0
1937                    for _ in range(epoch):
1938                        total += ZakatTracker.ZakatCut(float(rest) - float(total))
1939                    if total > 0:
1940                        if x not in plan:
1941                            plan[x] = {}
1942                        valid = True
1943                        brief[2] += total
1944                        plan[x][index] = {
1945                            'total': total,
1946                            'count': epoch,
1947                            'box_time': j,
1948                            'box_capital': _box[j]['capital'],
1949                            'box_rest': _box[j]['rest'],
1950                            'box_last': _box[j]['last'],
1951                            'box_total': _box[j]['total'],
1952                            'box_count': _box[j]['count'],
1953                            'box_log': _log[j]['desc'],
1954                            'exchange_rate': exchange['rate'],
1955                            'exchange_time': exchange['time'],
1956                            'exchange_desc': exchange['description'],
1957                        }
1958                else:
1959                    chunk = ZakatTracker.ZakatCut(float(rest))
1960                    if chunk > 0:
1961                        if x not in plan:
1962                            plan[x] = {}
1963                        if j not in plan[x].keys():
1964                            plan[x][index] = {}
1965                        below_nisab += rest
1966                        brief[2] += chunk
1967                        plan[x][index]['below_nisab'] = chunk
1968                        plan[x][index]['total'] = chunk
1969                        plan[x][index]['count'] = epoch
1970                        plan[x][index]['box_time'] = j
1971                        plan[x][index]['box_capital'] = _box[j]['capital']
1972                        plan[x][index]['box_rest'] = _box[j]['rest']
1973                        plan[x][index]['box_last'] = _box[j]['last']
1974                        plan[x][index]['box_total'] = _box[j]['total']
1975                        plan[x][index]['box_count'] = _box[j]['count']
1976                        plan[x][index]['box_log'] = _log[j]['desc']
1977                        plan[x][index]['exchange_rate'] = exchange['rate']
1978                        plan[x][index]['exchange_time'] = exchange['time']
1979                        plan[x][index]['exchange_desc'] = exchange['description']
1980        valid = valid or below_nisab >= nisab
1981        if debug:
1982            print(f"below_nisab({below_nisab}) >= nisab({nisab})")
1983        return valid, brief, plan

Check the eligibility for Zakat based on the given parameters.

Parameters:

  • silver_gram_price (float): The price of a gram of silver.
  • unscaled_nisab (float | int | decimal.Decimal): 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:
1985    def build_payment_parts(self, scaled_demand: int, positive_only: bool = True) -> dict:
1986        """
1987        Build payment parts for the Zakat distribution.
1988
1989        Parameters:
1990        - scaled_demand (int): The total demand for payment in local currency.
1991        - positive_only (bool): If True, only consider accounts with positive balance. Default is True.
1992
1993        Returns:
1994        - dict: A dictionary containing the payment parts for each account. The dictionary has the following structure:
1995        {
1996            'account': {
1997                'account_id': {'balance': float, 'rate': float, 'part': float},
1998                ...
1999            },
2000            'exceed': bool,
2001            'demand': int,
2002            'total': float,
2003        }
2004        """
2005        total = 0
2006        parts = {
2007            'account': {},
2008            'exceed': False,
2009            'demand': int(round(scaled_demand)),
2010        }
2011        for x, y in self.accounts().items():
2012            if positive_only and y <= 0:
2013                continue
2014            total += float(y)
2015            exchange = self.exchange(x)
2016            parts['account'][x] = {'balance': y, 'rate': exchange['rate'], 'part': 0}
2017        parts['total'] = total
2018        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:
2020    @staticmethod
2021    def check_payment_parts(parts: dict, debug: bool = False) -> int:
2022        """
2023        Checks the validity of payment parts.
2024
2025        Parameters:
2026        - parts (dict): A dictionary containing payment parts information.
2027        - debug (bool): Flag to enable debug mode.
2028
2029        Returns:
2030        - int: Returns 0 if the payment parts are valid, otherwise returns the error code.
2031
2032        Error Codes:
2033        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
2034        2: 'balance', 'rate' or 'part' key is missing in parts['account'][x].
2035        3: 'part' value in parts['account'][x] is less than 0.
2036        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
2037        5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
2038        6: The sum of 'part' values in parts['account'] does not match with 'demand' value.
2039        """
2040        if debug:
2041            print('check_payment_parts', f'debug={debug}')
2042        for i in ['demand', 'account', 'total', 'exceed']:
2043            if i not in parts:
2044                return 1
2045        exceed = parts['exceed']
2046        for x in parts['account']:
2047            for j in ['balance', 'rate', 'part']:
2048                if j not in parts['account'][x]:
2049                    return 2
2050                if parts['account'][x]['part'] < 0:
2051                    return 3
2052                if not exceed and parts['account'][x]['balance'] <= 0:
2053                    return 4
2054        demand = parts['demand']
2055        z = 0
2056        for _, y in parts['account'].items():
2057            if not exceed and y['part'] > y['balance']:
2058                return 5
2059            z += ZakatTracker.exchange_calc(y['part'], y['rate'], 1)
2060        z = round(z, 2)
2061        demand = round(demand, 2)
2062        if debug:
2063            print('check_payment_parts', f'z = {z}, demand = {demand}')
2064            print('check_payment_parts', type(z), type(demand))
2065            print('check_payment_parts', z != demand)
2066            print('check_payment_parts', str(z) != str(demand))
2067        if z != demand and str(z) != str(demand):
2068            return 6
2069        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:
2071    def zakat(self, report: tuple, parts: Dict[str, Dict | bool | Any] = None, debug: bool = False) -> bool:
2072        """
2073        Perform Zakat calculation based on the given report and optional parts.
2074
2075        Parameters:
2076        - report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan.
2077        - parts (dict): A dictionary containing the payment parts for the zakat.
2078        - debug (bool): A flag indicating whether to print debug information.
2079
2080        Returns:
2081        - bool: True if the zakat calculation is successful, False otherwise.
2082        """
2083        if debug:
2084            print('zakat', f'debug={debug}')
2085        valid, _, plan = report
2086        if not valid:
2087            return valid
2088        parts_exist = parts is not None
2089        if parts_exist:
2090            if self.check_payment_parts(parts, debug=debug) != 0:
2091                return False
2092        if debug:
2093            print('######### zakat #######')
2094            print('parts_exist', parts_exist)
2095        no_lock = self.nolock()
2096        lock = self.lock()
2097        report_time = self.time()
2098        self._vault['report'][report_time] = report
2099        self._step(Action.REPORT, ref=report_time)
2100        created = self.time()
2101        for x in plan:
2102            target_exchange = self.exchange(x)
2103            if debug:
2104                print(plan[x])
2105                print('-------------')
2106                print(self._vault['account'][x]['box'])
2107            ids = sorted(self._vault['account'][x]['box'].keys())
2108            if debug:
2109                print('plan[x]', plan[x])
2110            for i in plan[x].keys():
2111                j = ids[i]
2112                if debug:
2113                    print('i', i, 'j', j)
2114                self._step(Action.ZAKAT, account=x, ref=j, value=self._vault['account'][x]['box'][j]['last'],
2115                           key='last',
2116                           math_operation=MathOperation.EQUAL)
2117                self._vault['account'][x]['box'][j]['last'] = created
2118                amount = ZakatTracker.exchange_calc(float(plan[x][i]['total']), 1, float(target_exchange['rate']))
2119                self._vault['account'][x]['box'][j]['total'] += amount
2120                self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='total',
2121                           math_operation=MathOperation.ADDITION)
2122                self._vault['account'][x]['box'][j]['count'] += plan[x][i]['count']
2123                self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['count'], key='count',
2124                           math_operation=MathOperation.ADDITION)
2125                if not parts_exist:
2126                    try:
2127                        self._vault['account'][x]['box'][j]['rest'] -= amount
2128                    except TypeError:
2129                        self._vault['account'][x]['box'][j]['rest'] -= decimal.Decimal(amount)
2130                    # self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest',
2131                    #            math_operation=MathOperation.SUBTRACTION)
2132                    self._log(-float(amount), desc='zakat-زكاة', account=x, created=None, ref=j, debug=debug)
2133        if parts_exist:
2134            for account, part in parts['account'].items():
2135                if part['part'] == 0:
2136                    continue
2137                if debug:
2138                    print('zakat-part', account, part['rate'])
2139                target_exchange = self.exchange(account)
2140                amount = ZakatTracker.exchange_calc(part['part'], part['rate'], target_exchange['rate'])
2141                self.sub(
2142                    unscaled_value=self.unscale(int(amount)),
2143                    desc='zakat-part-دفعة-زكاة',
2144                    account=account,
2145                    debug=debug,
2146                )
2147        if no_lock:
2148            self.free(lock)
2149        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:
2151    def export_json(self, path: str = "data.json") -> bool:
2152        """
2153        Exports the current state of the ZakatTracker object to a JSON file.
2154
2155        Parameters:
2156        - path (str): The path where the JSON file will be saved. Default is "data.json".
2157
2158        Returns:
2159        - bool: True if the export is successful, False otherwise.
2160
2161        Raises:
2162        No specific exceptions are raised by this method.
2163        """
2164        with open(path, "w", encoding="utf-8") as file:
2165            json.dump(self._vault, file, indent=4, cls=JSONEncoder)
2166            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:
2168    def save(self, path: str = None) -> bool:
2169        """
2170        Saves the ZakatTracker's current state to a camel file.
2171
2172        This method serializes the internal data (`_vault`).
2173
2174        Parameters:
2175        - path (str, optional): File path for saving. Defaults to a predefined location.
2176
2177        Returns:
2178        - bool: True if the save operation is successful, False otherwise.
2179        """
2180        if path is None:
2181            path = self.path()
2182        try:
2183            # first save in tmp file
2184            with open(f'{path}.tmp', 'w', encoding="utf-8") as stream:
2185                stream.write(camel.dump(self._vault))
2186            # then move tmp file to original location
2187            shutil.move(f'{path}.tmp', path)
2188            return True
2189        except (IOError, OSError) as e:
2190            print(f"Error saving file: {e}")
2191            return False

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

This method serializes the internal data (_vault).

Parameters:

  • path (str, optional): File path for saving. Defaults to a predefined location.

Returns:

  • bool: True if the save operation is successful, False otherwise.
def load(self, path: str = None) -> bool:
2193    def load(self, path: str = None) -> bool:
2194        """
2195        Load the current state of the ZakatTracker object from a camel file.
2196
2197        Parameters:
2198        - path (str): The path where the camel file is located. If not provided, it will use the default path.
2199
2200        Returns:
2201        - bool: True if the load operation is successful, False otherwise.
2202        """
2203        if path is None:
2204            path = self.path()
2205        try:
2206            if os.path.exists(path):
2207                with open(path, 'r', encoding="utf-8") as stream:
2208                    self._vault = camel.load(stream.read())
2209                return True
2210            else:
2211                print(f"File not found: {path}")
2212                return False
2213        except (IOError, OSError) as e:
2214            print(f"Error loading file: {e}")
2215            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):
2217    def import_csv_cache_path(self):
2218        """
2219        Generates the cache file path for imported CSV data.
2220
2221        This function constructs the file path where cached data from CSV imports
2222        will be stored. The cache file is a camel file (.camel extension) appended
2223        to the base path of the object.
2224
2225        Returns:
2226        - str: The full path to the import CSV cache file.
2227
2228        Example:
2229            >>> obj = ZakatTracker('/data/reports')
2230            >>> obj.import_csv_cache_path()
2231            '/data/reports.import_csv.camel'
2232        """
2233        path = str(self.path())
2234        ext = self.ext()
2235        ext_len = len(ext)
2236        if path.endswith(f'.{ext}'):
2237            path = path[:-ext_len - 1]
2238        _, filename = os.path.split(path + f'.import_csv.{ext}')
2239        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:
2241    def import_csv(self, path: str = 'file.csv', scale_decimal_places: int = 0, debug: bool = False) -> tuple:
2242        """
2243        The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system.
2244
2245        Parameters:
2246        - path (str): The path to the CSV file. Default is 'file.csv'.
2247        - scale_decimal_places (int): The number of decimal places to scale the value. Default is 0.
2248        - debug (bool): A flag indicating whether to print debug information.
2249
2250        Returns:
2251        - tuple: A tuple containing the number of transactions created, the number of transactions found in the cache,
2252                and a dictionary of bad transactions.
2253
2254        Notes:
2255            * Currency Pair Assumption: This function assumes that the exchange rates stored for each account
2256                                        are appropriate for the currency pairs involved in the conversions.
2257            * The exchange rate for each account is based on the last encountered transaction rate that is not equal
2258                to 1.0 or the previous rate for that account.
2259            * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent
2260              transactions of the same account within the whole imported and existing dataset when doing `check` and
2261              `zakat` operations.
2262
2263        Example Usage:
2264            The CSV file should have the following format, rate is optional per transaction:
2265            account, desc, value, date, rate
2266            For example:
2267            safe-45, "Some text", 34872, 1988-06-30 00:00:00, 1
2268        """
2269        if debug:
2270            print('import_csv', f'debug={debug}')
2271        cache: list[int] = []
2272        try:
2273            with open(self.import_csv_cache_path(), 'r', encoding="utf-8") as stream:
2274                cache = camel.load(stream.read())
2275        except:
2276            pass
2277        date_formats = [
2278            "%Y-%m-%d %H:%M:%S",
2279            "%Y-%m-%dT%H:%M:%S",
2280            "%Y-%m-%dT%H%M%S",
2281            "%Y-%m-%d",
2282        ]
2283        created, found, bad = 0, 0, {}
2284        data: dict[int, list] = {}
2285        with open(path, newline='', encoding="utf-8") as f:
2286            i = 0
2287            for row in csv.reader(f, delimiter=','):
2288                i += 1
2289                hashed = hash(tuple(row))
2290                if hashed in cache:
2291                    found += 1
2292                    continue
2293                account = row[0]
2294                desc = row[1]
2295                value = float(row[2])
2296                rate = 1.0
2297                if row[4:5]:  # Empty list if index is out of range
2298                    rate = float(row[4])
2299                date: int = 0
2300                for time_format in date_formats:
2301                    try:
2302                        date = self.time(datetime.datetime.strptime(row[3], time_format))
2303                        break
2304                    except:
2305                        pass
2306                if date <= 0:
2307                    bad[i] = row + ['invalid date']
2308                if value == 0:
2309                    bad[i] = row + ['invalid value']
2310                    continue
2311                if date not in data:
2312                    data[date] = []
2313                data[date].append((i, account, desc, value, date, rate, hashed))
2314
2315        if debug:
2316            print('import_csv', len(data))
2317
2318        if bad:
2319            return created, found, bad
2320
2321        for date, rows in sorted(data.items()):
2322            try:
2323                len_rows = len(rows)
2324                if len_rows == 1:
2325                    (_, account, desc, unscaled_value, date, rate, hashed) = rows[0]
2326                    value = self.unscale(
2327                        unscaled_value,
2328                        decimal_places=scale_decimal_places,
2329                    ) if scale_decimal_places > 0 else unscaled_value
2330                    if rate > 0:
2331                        self.exchange(account=account, created=date, rate=rate)
2332                    if value > 0:
2333                        self.track(unscaled_value=value, desc=desc, account=account, logging=True, created=date)
2334                    elif value < 0:
2335                        self.sub(unscaled_value=-value, desc=desc, account=account, created=date)
2336                    created += 1
2337                    cache.append(hashed)
2338                    continue
2339                if debug:
2340                    print('-- Duplicated time detected', date, 'len', len_rows)
2341                    print(rows)
2342                    print('---------------------------------')
2343                # If records are found at the same time with different accounts in the same amount
2344                # (one positive and the other negative), this indicates it is a transfer.
2345                if len_rows != 2:
2346                    raise Exception(f'more than two transactions({len_rows}) at the same time')
2347                (i, account1, desc1, unscaled_value1, date1, rate1, _) = rows[0]
2348                (j, account2, desc2, unscaled_value2, date2, rate2, _) = rows[1]
2349                if account1 == account2 or desc1 != desc2 or abs(unscaled_value1) != abs(
2350                        unscaled_value2) or date1 != date2:
2351                    raise Exception('invalid transfer')
2352                if rate1 > 0:
2353                    self.exchange(account1, created=date1, rate=rate1)
2354                if rate2 > 0:
2355                    self.exchange(account2, created=date2, rate=rate2)
2356                value1 = self.unscale(
2357                    unscaled_value1,
2358                    decimal_places=scale_decimal_places,
2359                ) if scale_decimal_places > 0 else unscaled_value1
2360                value2 = self.unscale(
2361                    unscaled_value2,
2362                    decimal_places=scale_decimal_places,
2363                ) if scale_decimal_places > 0 else unscaled_value2
2364                values = {
2365                    value1: account1,
2366                    value2: account2,
2367                }
2368                self.transfer(
2369                    unscaled_amount=abs(value1),
2370                    from_account=values[min(values.keys())],
2371                    to_account=values[max(values.keys())],
2372                    desc=desc1,
2373                    created=date1,
2374                )
2375            except Exception as e:
2376                for (i, account, desc, value, date, rate, _) in rows:
2377                    bad[i] = (account, desc, value, date, rate, e)
2378                break
2379        with open(self.import_csv_cache_path(), 'w', encoding="utf-8") as stream:
2380            stream.write(camel.dump(cache))
2381        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:
2387    @staticmethod
2388    def human_readable_size(size: float, decimal_places: int = 2) -> str:
2389        """
2390        Converts a size in bytes to a human-readable format (e.g., KB, MB, GB).
2391
2392        This function iterates through progressively larger units of information
2393        (B, KB, MB, GB, etc.) and divides the input size until it fits within a
2394        range that can be expressed with a reasonable number before the unit.
2395
2396        Parameters:
2397        - size (float): The size in bytes to convert.
2398        - decimal_places (int, optional): The number of decimal places to display
2399            in the result. Defaults to 2.
2400
2401        Returns:
2402        - str: A string representation of the size in a human-readable format,
2403            rounded to the specified number of decimal places. For example:
2404                - "1.50 KB" (1536 bytes)
2405                - "23.00 MB" (24117248 bytes)
2406                - "1.23 GB" (1325899906 bytes)
2407        """
2408        if type(size) not in (float, int):
2409            raise TypeError("size must be a float or integer")
2410        if type(decimal_places) != int:
2411            raise TypeError("decimal_places must be an integer")
2412        for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']:
2413            if size < 1024.0:
2414                break
2415            size /= 1024.0
2416        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:
2418    @staticmethod
2419    def get_dict_size(obj: dict, seen: set = None) -> float:
2420        """
2421        Recursively calculates the approximate memory size of a dictionary and its contents in bytes.
2422
2423        This function traverses the dictionary structure, accounting for the size of keys, values,
2424        and any nested objects. It handles various data types commonly found in dictionaries
2425        (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case
2426        of circular references.
2427
2428        Parameters:
2429        - obj (dict): The dictionary whose size is to be calculated.
2430        - seen (set, optional): A set used internally to track visited objects
2431                             and avoid circular references. Defaults to None.
2432
2433        Returns:
2434         - float: An approximate size of the dictionary and its contents in bytes.
2435
2436        Note:
2437        - This function is a method of the `ZakatTracker` class and is likely used to
2438          estimate the memory footprint of data structures relevant to Zakat calculations.
2439        - The size calculation is approximate as it relies on `sys.getsizeof()`, which might
2440          not account for all memory overhead depending on the Python implementation.
2441        - Circular references are handled to prevent infinite recursion.
2442        - Basic numeric types (int, float, complex) are assumed to have fixed sizes.
2443        - String sizes are estimated based on character length and encoding.
2444        """
2445        size = 0
2446        if seen is None:
2447            seen = set()
2448
2449        obj_id = id(obj)
2450        if obj_id in seen:
2451            return 0
2452
2453        seen.add(obj_id)
2454        size += sys.getsizeof(obj)
2455
2456        if isinstance(obj, dict):
2457            for k, v in obj.items():
2458                size += ZakatTracker.get_dict_size(k, seen)
2459                size += ZakatTracker.get_dict_size(v, seen)
2460        elif isinstance(obj, (list, tuple, set, frozenset)):
2461            for item in obj:
2462                size += ZakatTracker.get_dict_size(item, seen)
2463        elif isinstance(obj, (int, float, complex)):  # Handle numbers
2464            pass  # Basic numbers have a fixed size, so nothing to add here
2465        elif isinstance(obj, str):  # Handle strings
2466            size += len(obj) * sys.getsizeof(str().encode())  # Size per character in bytes
2467        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:
2469    @staticmethod
2470    def duration_from_nanoseconds(ns: int,
2471                                  show_zeros_in_spoken_time: bool = False,
2472                                  spoken_time_separator=',',
2473                                  millennia: str = 'Millennia',
2474                                  century: str = 'Century',
2475                                  years: str = 'Years',
2476                                  days: str = 'Days',
2477                                  hours: str = 'Hours',
2478                                  minutes: str = 'Minutes',
2479                                  seconds: str = 'Seconds',
2480                                  milli_seconds: str = 'MilliSeconds',
2481                                  micro_seconds: str = 'MicroSeconds',
2482                                  nano_seconds: str = 'NanoSeconds',
2483                                  ) -> tuple:
2484        """
2485        REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
2486        Convert NanoSeconds to Human Readable Time Format.
2487        A NanoSeconds is a unit of time in the International System of Units (SI) equal
2488        to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
2489        Its symbol is μs, sometimes simplified to us when Unicode is not available.
2490        A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
2491
2492        INPUT : ms (AKA: MilliSeconds)
2493        OUTPUT: tuple(string time_lapsed, string spoken_time) like format.
2494        OUTPUT Variables: time_lapsed, spoken_time
2495
2496        Example  Input: duration_from_nanoseconds(ns)
2497        **"Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds"**
2498        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')
2499        duration_from_nanoseconds(1234567890123456789012)
2500        """
2501        us, ns = divmod(ns, 1000)
2502        ms, us = divmod(us, 1000)
2503        s, ms = divmod(ms, 1000)
2504        m, s = divmod(s, 60)
2505        h, m = divmod(m, 60)
2506        d, h = divmod(h, 24)
2507        y, d = divmod(d, 365)
2508        c, y = divmod(y, 100)
2509        n, c = divmod(c, 10)
2510        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}"
2511        spoken_time_part = []
2512        if n > 0 or show_zeros_in_spoken_time:
2513            spoken_time_part.append(f"{n: 3d} {millennia}")
2514        if c > 0 or show_zeros_in_spoken_time:
2515            spoken_time_part.append(f"{c: 4d} {century}")
2516        if y > 0 or show_zeros_in_spoken_time:
2517            spoken_time_part.append(f"{y: 3d} {years}")
2518        if d > 0 or show_zeros_in_spoken_time:
2519            spoken_time_part.append(f"{d: 4d} {days}")
2520        if h > 0 or show_zeros_in_spoken_time:
2521            spoken_time_part.append(f"{h: 2d} {hours}")
2522        if m > 0 or show_zeros_in_spoken_time:
2523            spoken_time_part.append(f"{m: 2d} {minutes}")
2524        if s > 0 or show_zeros_in_spoken_time:
2525            spoken_time_part.append(f"{s: 2d} {seconds}")
2526        if ms > 0 or show_zeros_in_spoken_time:
2527            spoken_time_part.append(f"{ms: 3d} {milli_seconds}")
2528        if us > 0 or show_zeros_in_spoken_time:
2529            spoken_time_part.append(f"{us: 3d} {micro_seconds}")
2530        if ns > 0 or show_zeros_in_spoken_time:
2531            spoken_time_part.append(f"{ns: 3d} {nano_seconds}")
2532        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:
2534    @staticmethod
2535    def day_to_time(day: int, month: int = 6, year: int = 2024) -> int:  # افتراض أن الشهر هو يونيو والسنة 2024
2536        """
2537        Convert a specific day, month, and year into a timestamp.
2538
2539        Parameters:
2540        - day (int): The day of the month.
2541        - month (int): The month of the year. Default is 6 (June).
2542        - year (int): The year. Default is 2024.
2543
2544        Returns:
2545        - int: The timestamp representing the given day, month, and year.
2546
2547        Note:
2548        This method assumes the default month and year if not provided.
2549        """
2550        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:
2552    @staticmethod
2553    def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
2554        """
2555        Generate a random date between two given dates.
2556
2557        Parameters:
2558        - start_date (datetime.datetime): The start date from which to generate a random date.
2559        - end_date (datetime.datetime): The end date until which to generate a random date.
2560
2561        Returns:
2562        - datetime.datetime: A random date between the start_date and end_date.
2563        """
2564        time_between_dates = end_date - start_date
2565        days_between_dates = time_between_dates.days
2566        random_number_of_days = random.randrange(days_between_dates)
2567        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:
2569    @staticmethod
2570    def generate_random_csv_file(path: str = "data.csv", count: int = 1000, with_rate: bool = False,
2571                                 debug: bool = False) -> int:
2572        """
2573        Generate a random CSV file with specified parameters.
2574
2575        Parameters:
2576        - path (str): The path where the CSV file will be saved. Default is "data.csv".
2577        - count (int): The number of rows to generate in the CSV file. Default is 1000.
2578        - with_rate (bool): If True, a random rate between 1.2% and 12% is added. Default is False.
2579        - debug (bool): A flag indicating whether to print debug information.
2580
2581        Returns:
2582        None. The function generates a CSV file at the specified path with the given count of rows.
2583        Each row contains a randomly generated account, description, value, and date.
2584        The value is randomly generated between 1000 and 100000,
2585        and the date is randomly generated between 1950-01-01 and 2023-12-31.
2586        If the row number is not divisible by 13, the value is multiplied by -1.
2587        """
2588        if debug:
2589            print('generate_random_csv_file', f'debug={debug}')
2590        i = 0
2591        with open(path, "w", newline="", encoding="utf-8") as csvfile:
2592            writer = csv.writer(csvfile)
2593            for i in range(count):
2594                account = f"acc-{random.randint(1, 1000)}"
2595                desc = f"Some text {random.randint(1, 1000)}"
2596                value = random.randint(1000, 100000)
2597                date = ZakatTracker.generate_random_date(datetime.datetime(1000, 1, 1),
2598                                                         datetime.datetime(2023, 12, 31)).strftime("%Y-%m-%d %H:%M:%S")
2599                if not i % 13 == 0:
2600                    value *= -1
2601                row = [account, desc, value, date]
2602                if with_rate:
2603                    rate = random.randint(1, 100) * 0.12
2604                    if debug:
2605                        print('before-append', row)
2606                    row.append(rate)
2607                    if debug:
2608                        print('after-append', row)
2609                writer.writerow(row)
2610                i = i + 1
2611        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):
2613    @staticmethod
2614    def create_random_list(max_sum, min_value=0, max_value=10):
2615        """
2616        Creates a list of random integers whose sum does not exceed the specified maximum.
2617
2618        Parameters:
2619        - max_sum: The maximum allowed sum of the list elements.
2620        - min_value: The minimum possible value for an element (inclusive).
2621        - max_value: The maximum possible value for an element (inclusive).
2622
2623        Returns:
2624        - A list of random integers.
2625        """
2626        result = []
2627        current_sum = 0
2628
2629        while current_sum < max_sum:
2630            # Calculate the remaining space for the next element
2631            remaining_sum = max_sum - current_sum
2632            # Determine the maximum possible value for the next element
2633            next_max_value = min(remaining_sum, max_value)
2634            # Generate a random element within the allowed range
2635            next_element = random.randint(min_value, next_max_value)
2636            result.append(next_element)
2637            current_sum += next_element
2638
2639        return result

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

Parameters:

  • 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:
2898    def test(self, debug: bool = False) -> bool:
2899        if debug:
2900            print('test', f'debug={debug}')
2901        try:
2902
2903            self._test_core(True, debug)
2904            self._test_core(False, debug)
2905
2906            assert self._history()
2907
2908            # Not allowed for duplicate transactions in the same account and time
2909
2910            created = ZakatTracker.time()
2911            self.track(100, 'test-1', 'same', True, created)
2912            failed = False
2913            try:
2914                self.track(50, 'test-1', 'same', True, created)
2915            except:
2916                failed = True
2917            assert failed is True
2918
2919            self.reset()
2920
2921            # Same account transfer
2922            for x in [1, 'a', True, 1.8, None]:
2923                failed = False
2924                try:
2925                    self.transfer(1, x, x, 'same-account', debug=debug)
2926                except:
2927                    failed = True
2928                assert failed is True
2929
2930            # Always preserve box age during transfer
2931
2932            series: list[tuple] = [
2933                (30, 4),
2934                (60, 3),
2935                (90, 2),
2936            ]
2937            case = {
2938                3000: {
2939                    'series': series,
2940                    'rest': 15000,
2941                },
2942                6000: {
2943                    'series': series,
2944                    'rest': 12000,
2945                },
2946                9000: {
2947                    'series': series,
2948                    'rest': 9000,
2949                },
2950                18000: {
2951                    'series': series,
2952                    'rest': 0,
2953                },
2954                27000: {
2955                    'series': series,
2956                    'rest': -9000,
2957                },
2958                36000: {
2959                    'series': series,
2960                    'rest': -18000,
2961                },
2962            }
2963
2964            selected_time = ZakatTracker.time() - ZakatTracker.TimeCycle()
2965
2966            for total in case:
2967                if debug:
2968                    print('--------------------------------------------------------')
2969                    print(f'case[{total}]', case[total])
2970                for x in case[total]['series']:
2971                    self.track(
2972                        unscaled_value=x[0],
2973                        desc=f"test-{x} ages",
2974                        account='ages',
2975                        logging=True,
2976                        created=selected_time * x[1],
2977                    )
2978
2979                unscaled_total = self.unscale(total)
2980                if debug:
2981                    print('unscaled_total', unscaled_total)
2982                refs = self.transfer(
2983                    unscaled_amount=unscaled_total,
2984                    from_account='ages',
2985                    to_account='future',
2986                    desc='Zakat Movement',
2987                    debug=debug,
2988                )
2989
2990                if debug:
2991                    print('refs', refs)
2992
2993                ages_cache_balance = self.balance('ages')
2994                ages_fresh_balance = self.balance('ages', False)
2995                rest = case[total]['rest']
2996                if debug:
2997                    print('source', ages_cache_balance, ages_fresh_balance, rest)
2998                assert ages_cache_balance == rest
2999                assert ages_fresh_balance == rest
3000
3001                future_cache_balance = self.balance('future')
3002                future_fresh_balance = self.balance('future', False)
3003                if debug:
3004                    print('target', future_cache_balance, future_fresh_balance, total)
3005                    print('refs', refs)
3006                assert future_cache_balance == total
3007                assert future_fresh_balance == total
3008
3009                # TODO: check boxes times for `ages` should equal box times in `future`
3010                for ref in self._vault['account']['ages']['box']:
3011                    ages_capital = self._vault['account']['ages']['box'][ref]['capital']
3012                    ages_rest = self._vault['account']['ages']['box'][ref]['rest']
3013                    future_capital = 0
3014                    if ref in self._vault['account']['future']['box']:
3015                        future_capital = self._vault['account']['future']['box'][ref]['capital']
3016                    future_rest = 0
3017                    if ref in self._vault['account']['future']['box']:
3018                        future_rest = self._vault['account']['future']['box'][ref]['rest']
3019                    if ages_capital != 0 and future_capital != 0 and future_rest != 0:
3020                        if debug:
3021                            print('================================================================')
3022                            print('ages', ages_capital, ages_rest)
3023                            print('future', future_capital, future_rest)
3024                        if ages_rest == 0:
3025                            assert ages_capital == future_capital
3026                        elif ages_rest < 0:
3027                            assert -ages_capital == future_capital
3028                        elif ages_rest > 0:
3029                            assert ages_capital == ages_rest + future_capital
3030                self.reset()
3031                assert len(self._vault['history']) == 0
3032
3033            assert self._history()
3034            assert self._history(False) is False
3035            assert self._history() is False
3036            assert self._history(True)
3037            assert self._history()
3038            if debug:
3039                print('####################################################################')
3040
3041            transaction = [
3042                (
3043                    20, 'wallet', 1, -2000, -2000, -2000, 1, 1,
3044                    2000, 2000, 2000, 1, 1,
3045                ),
3046                (
3047                    750, 'wallet', 'safe', -77000, -77000, -77000, 2, 2,
3048                    75000, 75000, 75000, 1, 1,
3049                ),
3050                (
3051                    600, 'safe', 'bank', 15000, 15000, 15000, 1, 2,
3052                    60000, 60000, 60000, 1, 1,
3053                ),
3054            ]
3055            for z in transaction:
3056                self.lock()
3057                x = z[1]
3058                y = z[2]
3059                self.transfer(
3060                    unscaled_amount=z[0],
3061                    from_account=x,
3062                    to_account=y,
3063                    desc='test-transfer',
3064                    debug=debug,
3065                )
3066                zz = self.balance(x)
3067                if debug:
3068                    print(zz, z)
3069                assert zz == z[3]
3070                xx = self.accounts()[x]
3071                assert xx == z[3]
3072                assert self.balance(x, False) == z[4]
3073                assert xx == z[4]
3074
3075                s = 0
3076                log = self._vault['account'][x]['log']
3077                for i in log:
3078                    s += log[i]['value']
3079                if debug:
3080                    print('s', s, 'z[5]', z[5])
3081                assert s == z[5]
3082
3083                assert self.box_size(x) == z[6]
3084                assert self.log_size(x) == z[7]
3085
3086                yy = self.accounts()[y]
3087                assert self.balance(y) == z[8]
3088                assert yy == z[8]
3089                assert self.balance(y, False) == z[9]
3090                assert yy == z[9]
3091
3092                s = 0
3093                log = self._vault['account'][y]['log']
3094                for i in log:
3095                    s += log[i]['value']
3096                assert s == z[10]
3097
3098                assert self.box_size(y) == z[11]
3099                assert self.log_size(y) == z[12]
3100                assert self.free(self.lock())
3101
3102            if debug:
3103                pp().pprint(self.check(2.17))
3104
3105            assert not self.nolock()
3106            history_count = len(self._vault['history'])
3107            if debug:
3108                print('history-count', history_count)
3109            assert history_count == 4
3110            assert not self.free(ZakatTracker.time())
3111            assert self.free(self.lock())
3112            assert self.nolock()
3113            assert len(self._vault['history']) == 3
3114
3115            # storage
3116
3117            _path = self.path(f'./zakat_test_db/test.{self.ext()}')
3118            if os.path.exists(_path):
3119                os.remove(_path)
3120            self.save()
3121            assert os.path.getsize(_path) > 0
3122            self.reset()
3123            assert self.recall(False, debug) is False
3124            self.load()
3125            assert self._vault['account'] is not None
3126
3127            # recall
3128
3129            assert self.nolock()
3130            assert len(self._vault['history']) == 3
3131            assert self.recall(False, debug) is True
3132            assert len(self._vault['history']) == 2
3133            assert self.recall(False, debug) is True
3134            assert len(self._vault['history']) == 1
3135            assert self.recall(False, debug) is True
3136            assert len(self._vault['history']) == 0
3137            assert self.recall(False, debug) is False
3138            assert len(self._vault['history']) == 0
3139
3140            # exchange
3141
3142            self.exchange("cash", 25, 3.75, "2024-06-25")
3143            self.exchange("cash", 22, 3.73, "2024-06-22")
3144            self.exchange("cash", 15, 3.69, "2024-06-15")
3145            self.exchange("cash", 10, 3.66)
3146
3147            for i in range(1, 30):
3148                exchange = self.exchange("cash", i)
3149                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
3150                if debug:
3151                    print(i, rate, description, created)
3152                assert created
3153                if i < 10:
3154                    assert rate == 1
3155                    assert description is None
3156                elif i == 10:
3157                    assert rate == 3.66
3158                    assert description is None
3159                elif i < 15:
3160                    assert rate == 3.66
3161                    assert description is None
3162                elif i == 15:
3163                    assert rate == 3.69
3164                    assert description is not None
3165                elif i < 22:
3166                    assert rate == 3.69
3167                    assert description is not None
3168                elif i == 22:
3169                    assert rate == 3.73
3170                    assert description is not None
3171                elif i >= 25:
3172                    assert rate == 3.75
3173                    assert description is not None
3174                exchange = self.exchange("bank", i)
3175                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
3176                if debug:
3177                    print(i, rate, description, created)
3178                assert created
3179                assert rate == 1
3180                assert description is None
3181
3182            assert len(self._vault['exchange']) > 0
3183            assert len(self.exchanges()) > 0
3184            self._vault['exchange'].clear()
3185            assert len(self._vault['exchange']) == 0
3186            assert len(self.exchanges()) == 0
3187
3188            # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية
3189            self.exchange("cash", ZakatTracker.day_to_time(25), 3.75, "2024-06-25")
3190            self.exchange("cash", ZakatTracker.day_to_time(22), 3.73, "2024-06-22")
3191            self.exchange("cash", ZakatTracker.day_to_time(15), 3.69, "2024-06-15")
3192            self.exchange("cash", ZakatTracker.day_to_time(10), 3.66)
3193
3194            for i in [x * 0.12 for x in range(-15, 21)]:
3195                if i <= 0:
3196                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) == 0
3197                else:
3198                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) > 0
3199
3200            # اختبار النتائج باستخدام التواريخ بالنانو ثانية
3201            for i in range(1, 31):
3202                timestamp_ns = ZakatTracker.day_to_time(i)
3203                exchange = self.exchange("cash", timestamp_ns)
3204                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
3205                if debug:
3206                    print(i, rate, description, created)
3207                assert created
3208                if i < 10:
3209                    assert rate == 1
3210                    assert description is None
3211                elif i == 10:
3212                    assert rate == 3.66
3213                    assert description is None
3214                elif i < 15:
3215                    assert rate == 3.66
3216                    assert description is None
3217                elif i == 15:
3218                    assert rate == 3.69
3219                    assert description is not None
3220                elif i < 22:
3221                    assert rate == 3.69
3222                    assert description is not None
3223                elif i == 22:
3224                    assert rate == 3.73
3225                    assert description is not None
3226                elif i >= 25:
3227                    assert rate == 3.75
3228                    assert description is not None
3229                exchange = self.exchange("bank", i)
3230                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
3231                if debug:
3232                    print(i, rate, description, created)
3233                assert created
3234                assert rate == 1
3235                assert description is None
3236
3237            # csv
3238
3239            csv_count = 1000
3240
3241            for with_rate, path in {
3242                False: 'test-import_csv-no-exchange',
3243                True: 'test-import_csv-with-exchange',
3244            }.items():
3245
3246                if debug:
3247                    print('test_import_csv', with_rate, path)
3248
3249                csv_path = path + '.csv'
3250                if os.path.exists(csv_path):
3251                    os.remove(csv_path)
3252                c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug)
3253                if debug:
3254                    print('generate_random_csv_file', c)
3255                assert c == csv_count
3256                assert os.path.getsize(csv_path) > 0
3257                cache_path = self.import_csv_cache_path()
3258                if os.path.exists(cache_path):
3259                    os.remove(cache_path)
3260                self.reset()
3261                (created, found, bad) = self.import_csv(csv_path, debug)
3262                bad_count = len(bad)
3263                assert bad_count > 0
3264                if debug:
3265                    print(f"csv-imported: ({created}, {found}, {bad_count}) = count({csv_count})")
3266                    print('bad', bad)
3267                tmp_size = os.path.getsize(cache_path)
3268                assert tmp_size > 0
3269                # TODO: assert created + found + bad_count == csv_count
3270                # TODO: assert created == csv_count
3271                # TODO: assert bad_count == 0
3272                (created_2, found_2, bad_2) = self.import_csv(csv_path)
3273                bad_2_count = len(bad_2)
3274                if debug:
3275                    print(f"csv-imported: ({created_2}, {found_2}, {bad_2_count})")
3276                    print('bad', bad)
3277                assert bad_2_count > 0
3278                # TODO: assert tmp_size == os.path.getsize(cache_path)
3279                # TODO: assert created_2 + found_2 + bad_2_count == csv_count
3280                # TODO: assert created == found_2
3281                # TODO: assert bad_count == bad_2_count
3282                # TODO: assert found_2 == csv_count
3283                # TODO: assert bad_2_count == 0
3284                # TODO: assert created_2 == 0
3285
3286                # payment parts
3287
3288                positive_parts = self.build_payment_parts(100, positive_only=True)
3289                assert self.check_payment_parts(positive_parts) != 0
3290                assert self.check_payment_parts(positive_parts) != 0
3291                all_parts = self.build_payment_parts(300, positive_only=False)
3292                assert self.check_payment_parts(all_parts) != 0
3293                assert self.check_payment_parts(all_parts) != 0
3294                if debug:
3295                    pp().pprint(positive_parts)
3296                    pp().pprint(all_parts)
3297                # dynamic discount
3298                suite = []
3299                count = 3
3300                for exceed in [False, True]:
3301                    case = []
3302                    for parts in [positive_parts, all_parts]:
3303                        part = parts.copy()
3304                        demand = part['demand']
3305                        if debug:
3306                            print(demand, part['total'])
3307                        i = 0
3308                        z = demand / count
3309                        cp = {
3310                            'account': {},
3311                            'demand': demand,
3312                            'exceed': exceed,
3313                            'total': part['total'],
3314                        }
3315                        j = ''
3316                        for x, y in part['account'].items():
3317                            x_exchange = self.exchange(x)
3318                            zz = self.exchange_calc(z, 1, x_exchange['rate'])
3319                            if exceed and zz <= demand:
3320                                i += 1
3321                                y['part'] = zz
3322                                if debug:
3323                                    print(exceed, y)
3324                                cp['account'][x] = y
3325                                case.append(y)
3326                            elif not exceed and y['balance'] >= zz:
3327                                i += 1
3328                                y['part'] = zz
3329                                if debug:
3330                                    print(exceed, y)
3331                                cp['account'][x] = y
3332                                case.append(y)
3333                            j = x
3334                            if i >= count:
3335                                break
3336                        if len(cp['account'][j]) > 0:
3337                            suite.append(cp)
3338                if debug:
3339                    print('suite', len(suite))
3340                # vault = self._vault.copy()
3341                for case in suite:
3342                    # self._vault = vault.copy()
3343                    if debug:
3344                        print('case', case)
3345                    result = self.check_payment_parts(case)
3346                    if debug:
3347                        print('check_payment_parts', result, f'exceed: {exceed}')
3348                    assert result == 0
3349
3350                    report = self.check(2.17, None, debug)
3351                    (valid, brief, plan) = report
3352                    if debug:
3353                        print('valid', valid)
3354                    zakat_result = self.zakat(report, parts=case, debug=debug)
3355                    if debug:
3356                        print('zakat-result', zakat_result)
3357                    assert valid == zakat_result
3358
3359            assert self.save(path + f'.{self.ext()}')
3360            assert self.export_json(path + '.json')
3361
3362            assert self.export_json("1000-transactions-test.json")
3363            assert self.save(f"1000-transactions-test.{self.ext()}")
3364
3365            self.reset()
3366
3367            # test transfer between accounts with different exchange rate
3368
3369            a_SAR = "Bank (SAR)"
3370            b_USD = "Bank (USD)"
3371            c_SAR = "Safe (SAR)"
3372            # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer
3373            for case in [
3374                (0, a_SAR, "SAR Gift", 1000, 100000),
3375                (1, a_SAR, 1),
3376                (0, b_USD, "USD Gift", 500, 50000),
3377                (1, b_USD, 1),
3378                (2, b_USD, 3.75),
3379                (1, b_USD, 3.75),
3380                (3, 100, b_USD, a_SAR, "100 USD -> SAR", 40000, 137500),
3381                (0, c_SAR, "Salary", 750, 75000),
3382                (3, 375, c_SAR, b_USD, "375 SAR -> USD", 37500, 50000),
3383                (3, 3.75, a_SAR, b_USD, "3.75 SAR -> USD", 137125, 50100),
3384            ]:
3385                if debug:
3386                    print('case', case)
3387                match (case[0]):
3388                    case 0:  # track
3389                        _, account, desc, x, balance = case
3390                        self.track(unscaled_value=x, desc=desc, account=account, debug=debug)
3391
3392                        cached_value = self.balance(account, cached=True)
3393                        fresh_value = self.balance(account, cached=False)
3394                        if debug:
3395                            print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value)
3396                        assert cached_value == balance
3397                        assert fresh_value == balance
3398                    case 1:  # check-exchange
3399                        _, account, expected_rate = case
3400                        t_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
3401                        if debug:
3402                            print('t-exchange', t_exchange)
3403                        assert t_exchange['rate'] == expected_rate
3404                    case 2:  # do-exchange
3405                        _, account, rate = case
3406                        self.exchange(account, rate=rate, debug=debug)
3407                        b_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
3408                        if debug:
3409                            print('b-exchange', b_exchange)
3410                        assert b_exchange['rate'] == rate
3411                    case 3:  # transfer
3412                        _, x, a, b, desc, a_balance, b_balance = case
3413                        self.transfer(x, a, b, desc, debug=debug)
3414
3415                        cached_value = self.balance(a, cached=True)
3416                        fresh_value = self.balance(a, cached=False)
3417                        if debug:
3418                            print(
3419                                'account', a,
3420                                'cached_value', cached_value,
3421                                'fresh_value', fresh_value,
3422                                'a_balance', a_balance,
3423                            )
3424                        assert cached_value == a_balance
3425                        assert fresh_value == a_balance
3426
3427                        cached_value = self.balance(b, cached=True)
3428                        fresh_value = self.balance(b, cached=False)
3429                        if debug:
3430                            print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value)
3431                        assert cached_value == b_balance
3432                        assert fresh_value == b_balance
3433
3434            # Transfer all in many chunks randomly from B to A
3435            a_SAR_balance = 137125
3436            b_USD_balance = 50100
3437            b_USD_exchange = self.exchange(b_USD)
3438            amounts = ZakatTracker.create_random_list(b_USD_balance, max_value=1000)
3439            if debug:
3440                print('amounts', amounts)
3441            i = 0
3442            for x in amounts:
3443                if debug:
3444                    print(f'{i} - transfer-with-exchange({x})')
3445                self.transfer(
3446                    unscaled_amount=self.unscale(x),
3447                    from_account=b_USD,
3448                    to_account=a_SAR,
3449                    desc=f"{x} USD -> SAR",
3450                    debug=debug,
3451                )
3452
3453                b_USD_balance -= x
3454                cached_value = self.balance(b_USD, cached=True)
3455                fresh_value = self.balance(b_USD, cached=False)
3456                if debug:
3457                    print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
3458                          b_USD_balance)
3459                assert cached_value == b_USD_balance
3460                assert fresh_value == b_USD_balance
3461
3462                a_SAR_balance += int(x * b_USD_exchange['rate'])
3463                cached_value = self.balance(a_SAR, cached=True)
3464                fresh_value = self.balance(a_SAR, cached=False)
3465                if debug:
3466                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
3467                          a_SAR_balance, 'rate', b_USD_exchange['rate'])
3468                assert cached_value == a_SAR_balance
3469                assert fresh_value == a_SAR_balance
3470                i += 1
3471
3472            # Transfer all in many chunks randomly from C to A
3473            c_SAR_balance = 37500
3474            amounts = ZakatTracker.create_random_list(c_SAR_balance, max_value=1000)
3475            if debug:
3476                print('amounts', amounts)
3477            i = 0
3478            for x in amounts:
3479                if debug:
3480                    print(f'{i} - transfer-with-exchange({x})')
3481                self.transfer(
3482                    unscaled_amount=self.unscale(x),
3483                    from_account=c_SAR,
3484                    to_account=a_SAR,
3485                    desc=f"{x} SAR -> a_SAR",
3486                    debug=debug,
3487                )
3488
3489                c_SAR_balance -= x
3490                cached_value = self.balance(c_SAR, cached=True)
3491                fresh_value = self.balance(c_SAR, cached=False)
3492                if debug:
3493                    print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
3494                          c_SAR_balance)
3495                assert cached_value == c_SAR_balance
3496                assert fresh_value == c_SAR_balance
3497
3498                a_SAR_balance += x
3499                cached_value = self.balance(a_SAR, cached=True)
3500                fresh_value = self.balance(a_SAR, cached=False)
3501                if debug:
3502                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
3503                          a_SAR_balance)
3504                assert cached_value == a_SAR_balance
3505                assert fresh_value == a_SAR_balance
3506                i += 1
3507
3508            assert self.export_json("accounts-transfer-with-exchange-rates.json")
3509            assert self.save(f"accounts-transfer-with-exchange-rates.{self.ext()}")
3510
3511            # check & zakat with exchange rates for many cycles
3512
3513            for rate, values in {
3514                1: {
3515                    'in': [1000, 2000, 10000],
3516                    'exchanged': [100000, 200000, 1000000],
3517                    'out': [2500, 5000, 73140],
3518                },
3519                3.75: {
3520                    'in': [200, 1000, 5000],
3521                    'exchanged': [75000, 375000, 1875000],
3522                    'out': [1875, 9375, 137138],
3523                },
3524            }.items():
3525                a, b, c = values['in']
3526                m, n, o = values['exchanged']
3527                x, y, z = values['out']
3528                if debug:
3529                    print('rate', rate, 'values', values)
3530                for case in [
3531                    (a, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
3532                        {'safe': {0: {'below_nisab': x}}},
3533                    ], False, m),
3534                    (b, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
3535                        {'safe': {0: {'count': 1, 'total': y}}},
3536                    ], True, n),
3537                    (c, 'cave', ZakatTracker.time() - (ZakatTracker.TimeCycle() * 3), [
3538                        {'cave': {0: {'count': 3, 'total': z}}},
3539                    ], True, o),
3540                ]:
3541                    if debug:
3542                        print(f"############# check(rate: {rate}) #############")
3543                        print('case', case)
3544                    self.reset()
3545                    self.exchange(account=case[1], created=case[2], rate=rate)
3546                    self.track(
3547                        unscaled_value=case[0],
3548                        desc='test-check',
3549                        account=case[1],
3550                        logging=True,
3551                        created=case[2],
3552                    )
3553                    assert self.snapshot()
3554
3555                    # assert self.nolock()
3556                    # history_size = len(self._vault['history'])
3557                    # print('history_size', history_size)
3558                    # assert history_size == 2
3559                    assert self.lock()
3560                    assert not self.nolock()
3561                    report = self.check(2.17, None, debug)
3562                    (valid, brief, plan) = report
3563                    if debug:
3564                        print('brief', brief)
3565                    assert valid == case[4]
3566                    assert case[5] == brief[0]
3567                    assert case[5] == brief[1]
3568
3569                    if debug:
3570                        pp().pprint(plan)
3571
3572                    for x in plan:
3573                        assert case[1] == x
3574                        if 'total' in case[3][0][x][0].keys():
3575                            assert case[3][0][x][0]['total'] == int(brief[2])
3576                            assert int(plan[x][0]['total']) == case[3][0][x][0]['total']
3577                            assert int(plan[x][0]['count']) == case[3][0][x][0]['count']
3578                        else:
3579                            assert plan[x][0]['below_nisab'] == case[3][0][x][0]['below_nisab']
3580                    if debug:
3581                        pp().pprint(report)
3582                    result = self.zakat(report, debug=debug)
3583                    if debug:
3584                        print('zakat-result', result, case[4])
3585                    assert result == case[4]
3586                    report = self.check(2.17, None, debug)
3587                    (valid, brief, plan) = report
3588                    assert valid is False
3589
3590            history_size = len(self._vault['history'])
3591            if debug:
3592                print('history_size', history_size)
3593            assert history_size == 3
3594            assert not self.nolock()
3595            assert self.recall(False, debug) is False
3596            self.free(self.lock())
3597            assert self.nolock()
3598
3599            for i in range(3, 0, -1):
3600                history_size = len(self._vault['history'])
3601                if debug:
3602                    print('history_size', history_size)
3603                assert history_size == i
3604                assert self.recall(False, debug) is True
3605
3606            assert self.nolock()
3607            assert self.recall(False, debug) is False
3608
3609            history_size = len(self._vault['history'])
3610            if debug:
3611                print('history_size', history_size)
3612            assert history_size == 0
3613
3614            account_size = len(self._vault['account'])
3615            if debug:
3616                print('account_size', account_size)
3617            assert account_size == 0
3618
3619            report_size = len(self._vault['report'])
3620            if debug:
3621                print('report_size', report_size)
3622            assert report_size == 0
3623
3624            assert self.nolock()
3625            return True
3626        except Exception as e:
3627            # pp().pprint(self._vault)
3628            assert self.export_json("test-snapshot.json")
3629            assert self.save(f"test-snapshot.{self.ext()}")
3630            raise e
def test(debug: bool = False):
3633def test(debug: bool = False):
3634    ledger = ZakatTracker("./zakat_test_db")
3635    start = time.time_ns()
3636    assert ledger.test(debug=debug)
3637    if debug:
3638        print("#########################")
3639        print("######## TEST DONE ########")
3640        print("#########################")
3641        print(ZakatTracker.duration_from_nanoseconds(time.time_ns() - start))
3642        print("#########################")
class Action(enum.Enum):
88class Action(enum.Enum):
89    CREATE = 0
90    TRACK = 1
91    LOG = 2
92    SUB = 3
93    ADD_FILE = 4
94    REMOVE_FILE = 5
95    BOX_TRANSFER = 6
96    EXCHANGE = 7
97    REPORT = 8
98    ZAKAT = 9
CREATE = <Action.CREATE: 0>
TRACK = <Action.TRACK: 1>
LOG = <Action.LOG: 2>
SUB = <Action.SUB: 3>
ADD_FILE = <Action.ADD_FILE: 4>
REMOVE_FILE = <Action.REMOVE_FILE: 5>
BOX_TRANSFER = <Action.BOX_TRANSFER: 6>
EXCHANGE = <Action.EXCHANGE: 7>
REPORT = <Action.REPORT: 8>
ZAKAT = <Action.ZAKAT: 9>
class JSONEncoder(json.encoder.JSONEncoder):
107class JSONEncoder(json.JSONEncoder):
108    def default(self, o):
109        if isinstance(o, Action) or isinstance(o, MathOperation):
110            return o.name  # Serialize as the enum member's name
111        elif isinstance(o, decimal.Decimal):
112            return float(o)
113        return super().default(o)

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, o):
108    def default(self, o):
109        if isinstance(o, Action) or isinstance(o, MathOperation):
110            return o.name  # Serialize as the enum member's name
111        elif isinstance(o, decimal.Decimal):
112            return float(o)
113        return super().default(o)

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):
101class MathOperation(enum.Enum):
102    ADDITION = 0
103    EQUAL = 1
104    SUBTRACTION = 2
ADDITION = <MathOperation.ADDITION: 0>
EQUAL = <MathOperation.EQUAL: 1>
SUBTRACTION = <MathOperation.SUBTRACTION: 2>
class WeekDay(enum.Enum):
78class WeekDay(enum.Enum):
79    Monday = 0
80    Tuesday = 1
81    Wednesday = 2
82    Thursday = 3
83    Friday = 4
84    Saturday = 5
85    Sunday = 6
Monday = <WeekDay.Monday: 0>
Tuesday = <WeekDay.Tuesday: 1>
Wednesday = <WeekDay.Wednesday: 2>
Thursday = <WeekDay.Thursday: 3>
Friday = <WeekDay.Friday: 4>
Saturday = <WeekDay.Saturday: 5>
Sunday = <WeekDay.Sunday: 6>
def start_file_server( database_path: str, database_callback: <built-in function callable> = None, csv_callback: <built-in function callable> = None, debug: bool = False) -> tuple:
 55def start_file_server(database_path: str, database_callback: callable = None, csv_callback: callable = None,
 56                      debug: bool = False) -> tuple:
 57    """
 58    Starts a multi-purpose WSGI server to manage file interactions for a Zakat application.
 59
 60    This server facilitates the following functionalities:
 61
 62    1. GET /{file_uuid}/get: Download the database file specified by `database_path`.
 63    2. GET /{file_uuid}/upload: Display an HTML form for uploading files.
 64    3. POST /{file_uuid}/upload: Handle file uploads, distinguishing between:
 65        - Database File (.db): Replaces the existing database with the uploaded one.
 66        - CSV File (.csv): Imports data from the CSV into the existing database.
 67
 68    Args:
 69        database_path (str): The path to the camel database file.
 70        database_callback (callable, optional): A function to call after a successful database upload.
 71                                                It receives the uploaded database path as its argument.
 72        csv_callback (callable, optional): A function to call after a successful CSV upload. It receives the uploaded CSV path,
 73                                           the database path, and the debug flag as its arguments.
 74        debug (bool, optional): If True, print debugging information. Defaults to False.
 75
 76    Returns:
 77        Tuple[str, str, str, threading.Thread, Callable[[], None]]: A tuple containing:
 78            - file_name (str): The name of the database file.
 79            - download_url (str): The URL to download the database file.
 80            - upload_url (str): The URL to access the file upload form.
 81            - server_thread (threading.Thread): The thread running the server.
 82            - shutdown_server (Callable[[], None]): A function to gracefully shut down the server.
 83
 84    Example:
 85        _, download_url, upload_url, server_thread, shutdown_server = start_file_server("zakat.db")
 86        print(f"Download database: {download_url}")
 87        print(f"Upload files: {upload_url}")
 88        server_thread.start()
 89        # ... later ...
 90        shutdown_server()
 91    """
 92    file_uuid = uuid.uuid4()
 93    file_name = os.path.basename(database_path)
 94
 95    port = find_available_port()
 96    download_url = f"http://localhost:{port}/{file_uuid}/get"
 97    upload_url = f"http://localhost:{port}/{file_uuid}/upload"
 98
 99    # Upload directory
100    upload_directory = "./uploads"
101    os.makedirs(upload_directory, exist_ok=True)
102
103    # HTML templates
104    upload_form = f"""
105    <html lang="en">
106        <head>
107            <title>Zakat File Server</title>
108        </head>
109    <body>
110    <h1>Zakat File Server</h1>
111    <h3>You can download the <a target="__blank" href="{download_url}">database file</a>...</h3>
112    <h3>Or upload a new file to restore a database or import `CSV` file:</h3>
113    <form action="/{file_uuid}/upload" method="post" enctype="multipart/form-data">
114        <input type="file" name="file" required><br/>
115        <input type="radio" id="{FileType.Database.value}" name="upload_type" value="{FileType.Database.value}" required>
116        <label for="database">Database File</label><br/>
117        <input type="radio"id="{FileType.CSV.value}" name="upload_type" value="{FileType.CSV.value}">
118        <label for="csv">CSV File</label><br/>
119        <input type="submit" value="Upload"><br/>
120    </form>
121    </body></html>
122    """
123
124    # WSGI application
125    def wsgi_app(environ, start_response):
126        path = environ.get('PATH_INFO', '')
127        method = environ.get('REQUEST_METHOD', 'GET')
128
129        if path == f"/{file_uuid}/get" and method == 'GET':
130            # GET: Serve the existing file
131            try:
132                with open(database_path, "rb") as f:
133                    file_content = f.read()
134                    
135                start_response('200 OK', [
136                    ('Content-type', 'application/octet-stream'),
137                    ('Content-Disposition', f'attachment; filename="{file_name}"'),
138                    ('Content-Length', str(len(file_content)))
139                ])
140                return [file_content]
141            except FileNotFoundError:
142                start_response('404 Not Found', [('Content-type', 'text/plain')])
143                return [b'File not found']
144                
145        elif path == f"/{file_uuid}/upload" and method == 'GET':
146            # GET: Serve the upload form
147            start_response('200 OK', [('Content-type', 'text/html')])
148            return [upload_form.encode()]
149            
150        elif path == f"/{file_uuid}/upload" and method == 'POST':
151            # POST: Handle file uploads
152            try:
153                # Get content length
154                content_length = int(environ.get('CONTENT_LENGTH', 0))
155                
156                # Get content type and boundary
157                content_type = environ.get('CONTENT_TYPE', '')
158                
159                # Read the request body
160                request_body = environ['wsgi.input'].read(content_length)
161                
162                # Create a file-like object from the request body
163                # request_body_file = io.BytesIO(request_body)
164                
165                # Parse the multipart form data using WSGI approach
166                # First, detect the boundary from content_type
167                boundary = None
168                for part in content_type.split(';'):
169                    part = part.strip()
170                    if part.startswith('boundary='):
171                        boundary = part[9:]
172                        if boundary.startswith('"') and boundary.endswith('"'):
173                            boundary = boundary[1:-1]
174                        break
175                
176                if not boundary:
177                    start_response('400 Bad Request', [('Content-type', 'text/plain')])
178                    return [b"Missing boundary in multipart form data"]
179                
180                # Process multipart data
181                parts = request_body.split(f'--{boundary}'.encode())
182                
183                # Initialize variables to store form data
184                upload_type = None
185                # file_item = None
186                file_data = None
187                filename = None
188                
189                # Process each part
190                for part in parts:
191                    if not part.strip():
192                        continue
193                    
194                    # Split header and body
195                    try:
196                        headers_raw, body = part.split(b'\r\n\r\n', 1)
197                        headers_text = headers_raw.decode('utf-8')
198                    except ValueError:
199                        continue
200                    
201                    # Parse headers
202                    headers = {}
203                    for header_line in headers_text.split('\r\n'):
204                        if ':' in header_line:
205                            name, value = header_line.split(':', 1)
206                            headers[name.strip().lower()] = value.strip()
207                    
208                    # Get content disposition
209                    content_disposition = headers.get('content-disposition', '')
210                    if not content_disposition.startswith('form-data'):
211                        continue
212                    
213                    # Extract field name
214                    field_name = None
215                    for item in content_disposition.split(';'):
216                        item = item.strip()
217                        if item.startswith('name='):
218                            field_name = item[5:].strip('"\'')
219                            break
220                    
221                    if not field_name:
222                        continue
223                    
224                    # Handle upload_type field
225                    if field_name == 'upload_type':
226                        # Remove trailing data including the boundary
227                        body_end = body.find(b'\r\n--')
228                        if body_end >= 0:
229                            body = body[:body_end]
230                        upload_type = body.decode('utf-8').strip()
231                    
232                    # Handle file field
233                    elif field_name == 'file':
234                        # Extract filename
235                        for item in content_disposition.split(';'):
236                            item = item.strip()
237                            if item.startswith('filename='):
238                                filename = item[9:].strip('"\'')
239                                break
240                        
241                        if filename:
242                            # Remove trailing data including the boundary
243                            body_end = body.find(b'\r\n--')
244                            if body_end >= 0:
245                                body = body[:body_end]
246                            file_data = body
247                
248                if debug:
249                    print('upload_type', upload_type)
250                    
251                if debug:
252                    print('upload_type:', upload_type)
253                    print('filename:', filename)
254                
255                if not upload_type or upload_type not in [FileType.Database.value, FileType.CSV.value]:
256                    start_response('400 Bad Request', [('Content-type', 'text/plain')])
257                    return [b"Invalid upload type"]
258                
259                if not filename or not file_data:
260                    start_response('400 Bad Request', [('Content-type', 'text/plain')])
261                    return [b"Missing file data"]
262                
263                if debug:
264                    print(f'Uploaded filename: {filename}')
265                
266                # Save the file
267                file_path = os.path.join(upload_directory, upload_type)
268                with open(file_path, 'wb') as f:
269                    f.write(file_data)
270                
271                # Process based on file type
272                if upload_type == FileType.Database.value:
273                    try:
274                        # Verify database file
275                        if database_callback is not None:
276                            database_callback(file_path)
277                        
278                        # Copy database into the original path
279                        shutil.copy2(file_path, database_path)
280                        
281                        start_response('200 OK', [('Content-type', 'text/plain')])
282                        return [b"Database file uploaded successfully."]
283                    except Exception as e:
284                        start_response('400 Bad Request', [('Content-type', 'text/plain')])
285                        return [str(e).encode()]
286                
287                elif upload_type == FileType.CSV.value:
288                    try:
289                        if csv_callback is not None:
290                            result = csv_callback(file_path, database_path, debug)
291                            if debug:
292                                print(f'CSV imported: {result}')
293                            if len(result[2]) != 0:
294                                start_response('200 OK', [('Content-type', 'application/json')])
295                                return [json.dumps(result).encode()]
296                        
297                        start_response('200 OK', [('Content-type', 'text/plain')])
298                        return [b"CSV file uploaded successfully."]
299                    except Exception as e:
300                        start_response('400 Bad Request', [('Content-type', 'text/plain')])
301                        return [str(e).encode()]
302            
303            except Exception as e:
304                start_response('500 Internal Server Error', [('Content-type', 'text/plain')])
305                return [f"Error processing upload: {str(e)}".encode()]
306        
307        else:
308            # 404 for anything else
309            start_response('404 Not Found', [('Content-type', 'text/plain')])
310            return [b'Not Found']
311    
312    # Create and start the server
313    httpd = make_server('localhost', port, wsgi_app)
314    server_thread = threading.Thread(target=httpd.serve_forever)
315    
316    def shutdown_server():
317        nonlocal httpd, server_thread
318        httpd.shutdown()
319        server_thread.join()  # Wait for the thread to finish
320    
321    return file_name, download_url, upload_url, server_thread, shutdown_server

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

This server facilitates the following functionalities:

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

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

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

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

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

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

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

Returns: int: The available TCP port number.

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

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

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