zakat
xxx

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

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

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

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

Initialize ZakatTracker with database path and history mode.

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

Returns: None

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

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

Calculates the Zakat amount due on an asset.

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

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

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

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

Calculates the approximate duration of a lunar year in nanoseconds.

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

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

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

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

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

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

Examples:

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

Unscales an integer by a power of 10.

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

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

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

def reset(self) -> None:
478    def reset(self) -> None:
479        """
480        Reset the internal data structure to its initial state.
481
482        Parameters:
483        None
484
485        Returns:
486        None
487        """
488        self._vault = {
489            'account': {},
490            'exchange': {},
491            'history': {},
492            'lock': None,
493            'report': {},
494        }

Reset the internal data structure to its initial state.

Parameters: None

Returns: None

@staticmethod
def minimum_time_diff_ns() -> tuple[int, int]:
499    @staticmethod
500    def minimum_time_diff_ns() -> tuple[int, int]:
501        """
502        Calculates the minimum time difference between two consecutive calls to
503        `ZakatTracker._time()` in nanoseconds.
504
505        This method is used internally to determine the minimum granularity of
506        time measurements within the system.
507
508        Returns:
509        tuple[int, int]:
510            - The minimum time difference in nanoseconds.
511            - The number of iterations required to measure the difference.
512        """
513        i = 0
514        x = y = ZakatTracker._time()
515        while x == y:
516            y = ZakatTracker._time()
517            i += 1
518        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:
537    @staticmethod
538    def time(now: datetime.datetime = None) -> int:
539        """
540        Generates a unique, monotonically increasing timestamp based on the provided
541        datetime object or the current datetime.
542
543        This method ensures that timestamps are unique even if called in rapid succession
544        by introducing a small delay if necessary, based on the system's minimum
545        time resolution.
546
547        Parameters:
548        now (datetime.datetime, optional): The datetime object to generate the timestamp from.
549        If not provided, the current datetime is used.
550
551        Returns:
552        int: The unique timestamp in nanoseconds since the epoch (January 1, 1AD).
553        """
554        new_time = ZakatTracker._time(now)
555        if ZakatTracker._last_time_ns is None:
556            ZakatTracker._last_time_ns = new_time
557            return new_time
558        while new_time == ZakatTracker._last_time_ns:
559            if ZakatTracker._time_diff_ns is None:
560                diff, _ = ZakatTracker.minimum_time_diff_ns()
561                ZakatTracker._time_diff_ns = math.ceil(diff)
562            time.sleep(ZakatTracker._time_diff_ns / 1_000_000_000)
563            new_time = ZakatTracker._time()
564        ZakatTracker._last_time_ns = new_time
565        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:
567    @staticmethod
568    def time_to_datetime(ordinal_ns: int) -> datetime.datetime:
569        """
570        Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD)
571        back to a datetime object.
572
573        Parameters:
574        ordinal_ns (int): The timestamp in nanoseconds since the epoch (January 1, 1AD).
575
576        Returns:
577        datetime.datetime: The corresponding datetime object.
578        """
579        d = datetime.datetime.fromordinal(ordinal_ns // 86_400_000_000_000)
580        t = datetime.timedelta(seconds=(ordinal_ns % 86_400_000_000_000) // 10 ** 9)
581        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:
583    def clean_history(self, lock: int | None = None) -> int:
584        """
585        Cleans up the empty history records of actions performed on the ZakatTracker instance.
586
587        Parameters:
588        lock (int, optional): The lock ID is used to clean up the empty history.
589            If not provided, it cleans up the empty history records for all locks.
590
591        Returns:
592        int: The number of locks cleaned up.
593        """
594        count = 0
595        if lock in self._vault['history']:
596            if len(self._vault['history'][lock]) <= 0:
597                count += 1
598                del self._vault['history'][lock]
599            return count
600        self.free(self.lock())
601        for lock in self._vault['history']:
602            if len(self._vault['history'][lock]) <= 0:
603                count += 1
604                del self._vault['history'][lock]
605        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:
661    def nolock(self) -> bool:
662        """
663        Check if the vault lock is currently not set.
664
665        Returns:
666        bool: True if the vault lock is not set, False otherwise.
667        """
668        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:
670    def lock(self) -> int:
671        """
672        Acquires a lock on the ZakatTracker instance.
673
674        Returns:
675        int: The lock ID. This ID can be used to release the lock later.
676        """
677        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:
679    def steps(self) -> dict:
680        """
681        Returns a copy of the history of steps taken in the ZakatTracker.
682
683        The history is a dictionary where each key is a unique identifier for a step,
684        and the corresponding value is a dictionary containing information about the step.
685
686        Returns:
687        dict: A copy of the history of steps taken in the ZakatTracker.
688        """
689        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:
691    def free(self, lock: int, auto_save: bool = True) -> bool:
692        """
693        Releases the lock on the database.
694
695        Parameters:
696        lock (int): The lock ID to be released.
697        auto_save (bool): Whether to automatically save the database after releasing the lock.
698
699        Returns:
700        bool: True if the lock is successfully released and (optionally) saved, False otherwise.
701        """
702        if lock == self._vault['lock']:
703            self._vault['lock'] = None
704            self.clean_history(lock)
705            if auto_save:
706                return self.save(self.path())
707            return True
708        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:
710    def recall(self, dry: bool = True, debug: bool = False) -> bool:
711        """
712        Revert the last operation.
713
714        Parameters:
715        dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
716        debug (bool): If True, the function will print debug information. Default is False.
717
718        Returns:
719        bool: True if the operation was successful, False otherwise.
720        """
721        if not self.nolock() or len(self._vault['history']) == 0:
722            return False
723        if len(self._vault['history']) <= 0:
724            return False
725        ref = sorted(self._vault['history'].keys())[-1]
726        if debug:
727            print('recall', ref)
728        memory = self._vault['history'][ref]
729        if debug:
730            print(type(memory), 'memory', memory)
731        limit = len(memory) + 1
732        sub_positive_log_negative = 0
733        for i in range(-1, -limit, -1):
734            x = memory[i]
735            if debug:
736                print(type(x), x)
737            match x['action']:
738                case Action.CREATE:
739                    if x['account'] is not None:
740                        if self.account_exists(x['account']):
741                            if debug:
742                                print('account', self._vault['account'][x['account']])
743                            assert len(self._vault['account'][x['account']]['box']) == 0
744                            assert self._vault['account'][x['account']]['balance'] == 0
745                            assert self._vault['account'][x['account']]['count'] == 0
746                            if dry:
747                                continue
748                            del self._vault['account'][x['account']]
749
750                case Action.TRACK:
751                    if x['account'] is not None:
752                        if self.account_exists(x['account']):
753                            if dry:
754                                continue
755                            self._vault['account'][x['account']]['balance'] -= x['value']
756                            self._vault['account'][x['account']]['count'] -= 1
757                            del self._vault['account'][x['account']]['box'][x['ref']]
758
759                case Action.LOG:
760                    if x['account'] is not None:
761                        if self.account_exists(x['account']):
762                            if x['ref'] in self._vault['account'][x['account']]['log']:
763                                if dry:
764                                    continue
765                                if sub_positive_log_negative == -x['value']:
766                                    self._vault['account'][x['account']]['count'] -= 1
767                                    sub_positive_log_negative = 0
768                                box_ref = self._vault['account'][x['account']]['log'][x['ref']]['ref']
769                                if not box_ref is None:
770                                    assert self.box_exists(x['account'], box_ref)
771                                    box_value = self._vault['account'][x['account']]['log'][x['ref']]['value']
772                                    assert box_value < 0
773
774                                    try:
775                                        self._vault['account'][x['account']]['box'][box_ref]['rest'] += -box_value
776                                    except TypeError:
777                                        self._vault['account'][x['account']]['box'][box_ref]['rest'] += decimal.Decimal(
778                                            -box_value)
779
780                                    try:
781                                        self._vault['account'][x['account']]['balance'] += -box_value
782                                    except TypeError:
783                                        self._vault['account'][x['account']]['balance'] += decimal.Decimal(-box_value)
784
785                                    self._vault['account'][x['account']]['count'] -= 1
786                                del self._vault['account'][x['account']]['log'][x['ref']]
787
788                case Action.SUB:
789                    if x['account'] is not None:
790                        if self.account_exists(x['account']):
791                            if x['ref'] in self._vault['account'][x['account']]['box']:
792                                if dry:
793                                    continue
794                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] += x['value']
795                                self._vault['account'][x['account']]['balance'] += x['value']
796                                sub_positive_log_negative = x['value']
797
798                case Action.ADD_FILE:
799                    if x['account'] is not None:
800                        if self.account_exists(x['account']):
801                            if x['ref'] in self._vault['account'][x['account']]['log']:
802                                if x['file'] in self._vault['account'][x['account']]['log'][x['ref']]['file']:
803                                    if dry:
804                                        continue
805                                    del self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']]
806
807                case Action.REMOVE_FILE:
808                    if x['account'] is not None:
809                        if self.account_exists(x['account']):
810                            if x['ref'] in self._vault['account'][x['account']]['log']:
811                                if dry:
812                                    continue
813                                self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']] = x['value']
814
815                case Action.BOX_TRANSFER:
816                    if x['account'] is not None:
817                        if self.account_exists(x['account']):
818                            if x['ref'] in self._vault['account'][x['account']]['box']:
819                                if dry:
820                                    continue
821                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] -= x['value']
822
823                case Action.EXCHANGE:
824                    if x['account'] is not None:
825                        if x['account'] in self._vault['exchange']:
826                            if x['ref'] in self._vault['exchange'][x['account']]:
827                                if dry:
828                                    continue
829                                del self._vault['exchange'][x['account']][x['ref']]
830
831                case Action.REPORT:
832                    if x['ref'] in self._vault['report']:
833                        if dry:
834                            continue
835                        del self._vault['report'][x['ref']]
836
837                case Action.ZAKAT:
838                    if x['account'] is not None:
839                        if self.account_exists(x['account']):
840                            if x['ref'] in self._vault['account'][x['account']]['box']:
841                                if x['key'] in self._vault['account'][x['account']]['box'][x['ref']]:
842                                    if dry:
843                                        continue
844                                    match x['math']:
845                                        case MathOperation.ADDITION:
846                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] -= x[
847                                                'value']
848                                        case MathOperation.EQUAL:
849                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] = x['value']
850                                        case MathOperation.SUBTRACTION:
851                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] += x[
852                                                'value']
853
854        if not dry:
855            del self._vault['history'][ref]
856        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:
858    def vault(self) -> dict:
859        """
860        Returns a copy of the internal vault dictionary.
861
862        This method is used to retrieve the current state of the ZakatTracker object.
863        It provides a snapshot of the internal data structure, allowing for further
864        processing or analysis.
865
866        Returns:
867        dict: A copy of the internal vault dictionary.
868        """
869        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]]:
871    def stats_init(self) -> dict[str, tuple[int, str]]:
872        """
873        Initialize and return a dictionary containing initial statistics for the ZakatTracker instance.
874
875        The dictionary contains two keys: 'database' and 'ram'. Each key maps to a tuple containing two elements:
876        - The initial size of the respective statistic in bytes (int).
877        - The initial size of the respective statistic in a human-readable format (str).
878
879        Returns:
880        dict[str, tuple]: A dictionary with initial statistics for the ZakatTracker instance.
881        """
882        return {
883            'database': (0, '0'),
884            'ram': (0, '0'),
885        }

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

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

This function tracks a transaction for a specific account.

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.

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

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

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

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

Parameters:

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

Returns:

  • dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found, it returns a dictionary with default values for the rate and description.
@staticmethod
def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
1312    @staticmethod
1313    def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
1314        """
1315        This function calculates the exchanged amount of a currency.
1316
1317        Args:
1318            x (float): The original amount of the currency.
1319            x_rate (float): The exchange rate of the original currency.
1320            y_rate (float): The exchange rate of the target currency.
1321
1322        Returns:
1323            float: The exchanged amount of the target currency.
1324        """
1325        return (x * x_rate) / y_rate

This function calculates the exchanged amount of a currency.

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

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

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

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

Calculate and return the balance of a specific account.

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

Returns: int: The balance of the account.

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

def hide(self, account, status: bool = None) -> bool:
1628    def hide(self, account, status: bool = None) -> bool:
1629        """
1630        Check or set the hide status of a specific account.
1631
1632        Parameters:
1633        account (str): The account number.
1634        status (bool, optional): The new hide status. If not provided, the function will return the current status.
1635
1636        Returns:
1637        bool: The current or updated hide status of the account.
1638
1639        Raises:
1640        None
1641
1642        Example:
1643        >>> tracker = ZakatTracker()
1644        >>> ref = tracker.track(51, 'desc', 'account1')
1645        >>> tracker.hide('account1')  # Set the hide status of 'account1' to True
1646        False
1647        >>> tracker.hide('account1', True)  # Set the hide status of 'account1' to True
1648        True
1649        >>> tracker.hide('account1')  # Get the hide status of 'account1' by default
1650        True
1651        >>> tracker.hide('account1', False)
1652        False
1653        """
1654        if self.account_exists(account):
1655            if status is None:
1656                return self._vault['account'][account]['hide']
1657            self._vault['account'][account]['hide'] = status
1658            return status
1659        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:
1661    def zakatable(self, account, status: bool = None) -> bool:
1662        """
1663        Check or set the zakatable status of a specific account.
1664
1665        Parameters:
1666        account (str): The account number.
1667        status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
1668
1669        Returns:
1670        bool: The current or updated zakatable status of the account.
1671
1672        Raises:
1673        None
1674
1675        Example:
1676        >>> tracker = ZakatTracker()
1677        >>> ref = tracker.track(51, 'desc', 'account1')
1678        >>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
1679        True
1680        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
1681        True
1682        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
1683        True
1684        >>> tracker.zakatable('account1', False)
1685        False
1686        """
1687        if self.account_exists(account):
1688            if status is None:
1689                return self._vault['account'][account]['zakatable']
1690            self._vault['account'][account]['zakatable'] = status
1691            return status
1692        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:
1694    def sub(self, unscaled_value: float | int | decimal.Decimal, desc: str = '', account: str = 1, created: int = None,
1695            debug: bool = False) \
1696            -> tuple[
1697                   int,
1698                   list[
1699                       tuple[int, int],
1700                   ],
1701               ] | tuple:
1702        """
1703        Subtracts a specified value from an account's balance.
1704
1705        Parameters:
1706        unscaled_value (float | int | decimal.Decimal): The amount to be subtracted.
1707        desc (str): A description for the transaction. Defaults to an empty string.
1708        account (str): The account from which the value will be subtracted. Defaults to '1'.
1709        created (int): The timestamp of the transaction. If not provided, the current timestamp will be used.
1710        debug (bool): A flag indicating whether to print debug information. Defaults to False.
1711
1712        Returns:
1713        tuple: A tuple containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
1714
1715        If the amount to subtract is greater than the account's balance,
1716        the remaining amount will be transferred to a new transaction with a negative value.
1717
1718        Raises:
1719        ValueError: The box transaction happened again in the same nanosecond time.
1720        ValueError: The log transaction happened again in the same nanosecond time.
1721        """
1722        if debug:
1723            print('sub', f'debug={debug}')
1724        if unscaled_value < 0:
1725            return tuple()
1726        if unscaled_value == 0:
1727            ref = self.track(unscaled_value, '', account)
1728            return ref, ref
1729        if created is None:
1730            created = self.time()
1731        no_lock = self.nolock()
1732        self.lock()
1733        self.track(0, '', account)
1734        value = self.scale(unscaled_value)
1735        self._log(value=-value, desc=desc, account=account, created=created, ref=None, debug=debug)
1736        ids = sorted(self._vault['account'][account]['box'].keys())
1737        limit = len(ids) + 1
1738        target = value
1739        if debug:
1740            print('ids', ids)
1741        ages = []
1742        for i in range(-1, -limit, -1):
1743            if target == 0:
1744                break
1745            j = ids[i]
1746            if debug:
1747                print('i', i, 'j', j)
1748            rest = self._vault['account'][account]['box'][j]['rest']
1749            if rest >= target:
1750                self._vault['account'][account]['box'][j]['rest'] -= target
1751                self._step(Action.SUB, account, ref=j, value=target)
1752                ages.append((j, target))
1753                target = 0
1754                break
1755            elif target > rest > 0:
1756                chunk = rest
1757                target -= chunk
1758                self._step(Action.SUB, account, ref=j, value=chunk)
1759                ages.append((j, chunk))
1760                self._vault['account'][account]['box'][j]['rest'] = 0
1761        if target > 0:
1762            self.track(
1763                unscaled_value=self.unscale(-target),
1764                desc=desc,
1765                account=account,
1766                logging=False,
1767                created=created,
1768            )
1769            ages.append((created, target))
1770        if no_lock:
1771            self.free(self.lock())
1772        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 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]:
1774    def transfer(self, unscaled_amount: float | int | decimal.Decimal, from_account: str, to_account: str, desc: str = '',
1775                 created: int = None,
1776                 debug: bool = False) -> list[int]:
1777        """
1778        Transfers a specified value from one account to another.
1779
1780        Parameters:
1781        unscaled_amount (float | int | decimal.Decimal): The amount to be transferred.
1782        from_account (str): The account from which the value will be transferred.
1783        to_account (str): The account to which the value will be transferred.
1784        desc (str, optional): A description for the transaction. Defaults to an empty string.
1785        created (int, optional): The timestamp of the transaction. If not provided, the current timestamp will be used.
1786        debug (bool): A flag indicating whether to print debug information. Defaults to False.
1787
1788        Returns:
1789        list[int]: A list of timestamps corresponding to the transactions made during the transfer.
1790
1791        Raises:
1792        ValueError: Transfer to the same account is forbidden.
1793        ValueError: The box transaction happened again in the same nanosecond time.
1794        ValueError: The log transaction happened again in the same nanosecond time.
1795        """
1796        if debug:
1797            print('transfer', f'debug={debug}')
1798        if from_account == to_account:
1799            raise ValueError(f'Transfer to the same account is forbidden. {to_account}')
1800        if unscaled_amount <= 0:
1801            return []
1802        if created is None:
1803            created = self.time()
1804        (_, ages) = self.sub(unscaled_amount, desc, from_account, created, debug=debug)
1805        times = []
1806        source_exchange = self.exchange(from_account, created)
1807        target_exchange = self.exchange(to_account, created)
1808
1809        if debug:
1810            print('ages', ages)
1811
1812        for age, value in ages:
1813            target_amount = int(self.exchange_calc(value, source_exchange['rate'], target_exchange['rate']))
1814            if debug:
1815                print('target_amount', target_amount)
1816            # Perform the transfer
1817            if self.box_exists(to_account, age):
1818                if debug:
1819                    print('box_exists', age)
1820                capital = self._vault['account'][to_account]['box'][age]['capital']
1821                rest = self._vault['account'][to_account]['box'][age]['rest']
1822                if debug:
1823                    print(
1824                        f"Transfer(loop) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1825                selected_age = age
1826                if rest + target_amount > capital:
1827                    self._vault['account'][to_account]['box'][age]['capital'] += target_amount
1828                    selected_age = ZakatTracker.time()
1829                self._vault['account'][to_account]['box'][age]['rest'] += target_amount
1830                self._step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount)
1831                y = self._log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account,
1832                              created=None, ref=None, debug=debug)
1833                times.append((age, y))
1834                continue
1835            if debug:
1836                print(
1837                    f"Transfer(func) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1838            y = self.track(
1839                unscaled_value=self.unscale(int(target_amount)),
1840                desc=desc,
1841                account=to_account,
1842                logging=True,
1843                created=age,
1844                debug=debug,
1845            )
1846            times.append(y)
1847        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 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:
1849    def check(self,
1850              silver_gram_price: float,
1851              unscaled_nisab: float | int | decimal.Decimal = None,
1852              debug: bool = False,
1853              now: int = None,
1854              cycle: float = None) -> tuple:
1855        """
1856        Check the eligibility for Zakat based on the given parameters.
1857
1858        Parameters:
1859        silver_gram_price (float): The price of a gram of silver.
1860        unscaled_nisab (float | int | decimal.Decimal): The minimum amount of wealth required for Zakat. If not provided,
1861                        it will be calculated based on the silver_gram_price.
1862        debug (bool): Flag to enable debug mode.
1863        now (int): The current timestamp. If not provided, it will be calculated using ZakatTracker.time().
1864        cycle (float): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
1865
1866        Returns:
1867        tuple: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics,
1868        and a dictionary containing the Zakat plan.
1869        """
1870        if debug:
1871            print('check', f'debug={debug}')
1872        if now is None:
1873            now = self.time()
1874        if cycle is None:
1875            cycle = ZakatTracker.TimeCycle()
1876        if unscaled_nisab is None:
1877            unscaled_nisab = ZakatTracker.Nisab(silver_gram_price)
1878        nisab = self.scale(unscaled_nisab)
1879        plan = {}
1880        below_nisab = 0
1881        brief = [0, 0, 0]
1882        valid = False
1883        if debug:
1884            print('exchanges', self.exchanges())
1885        for x in self._vault['account']:
1886            if not self.zakatable(x):
1887                continue
1888            _box = self._vault['account'][x]['box']
1889            _log = self._vault['account'][x]['log']
1890            limit = len(_box) + 1
1891            ids = sorted(self._vault['account'][x]['box'].keys())
1892            for i in range(-1, -limit, -1):
1893                j = ids[i]
1894                rest = float(_box[j]['rest'])
1895                if rest <= 0:
1896                    continue
1897                exchange = self.exchange(x, created=self.time())
1898                rest = ZakatTracker.exchange_calc(rest, float(exchange['rate']), 1)
1899                brief[0] += rest
1900                index = limit + i - 1
1901                epoch = (now - j) / cycle
1902                if debug:
1903                    print(f"Epoch: {epoch}", _box[j])
1904                if _box[j]['last'] > 0:
1905                    epoch = (now - _box[j]['last']) / cycle
1906                if debug:
1907                    print(f"Epoch: {epoch}")
1908                epoch = math.floor(epoch)
1909                if debug:
1910                    print(f"Epoch: {epoch}", type(epoch), epoch == 0, 1 - epoch, epoch)
1911                if epoch == 0:
1912                    continue
1913                if debug:
1914                    print("Epoch - PASSED")
1915                brief[1] += rest
1916                if rest >= nisab:
1917                    total = 0
1918                    for _ in range(epoch):
1919                        total += ZakatTracker.ZakatCut(float(rest) - float(total))
1920                    if total > 0:
1921                        if x not in plan:
1922                            plan[x] = {}
1923                        valid = True
1924                        brief[2] += total
1925                        plan[x][index] = {
1926                            'total': total,
1927                            'count': epoch,
1928                            'box_time': j,
1929                            'box_capital': _box[j]['capital'],
1930                            'box_rest': _box[j]['rest'],
1931                            'box_last': _box[j]['last'],
1932                            'box_total': _box[j]['total'],
1933                            'box_count': _box[j]['count'],
1934                            'box_log': _log[j]['desc'],
1935                            'exchange_rate': exchange['rate'],
1936                            'exchange_time': exchange['time'],
1937                            'exchange_desc': exchange['description'],
1938                        }
1939                else:
1940                    chunk = ZakatTracker.ZakatCut(float(rest))
1941                    if chunk > 0:
1942                        if x not in plan:
1943                            plan[x] = {}
1944                        if j not in plan[x].keys():
1945                            plan[x][index] = {}
1946                        below_nisab += rest
1947                        brief[2] += chunk
1948                        plan[x][index]['below_nisab'] = chunk
1949                        plan[x][index]['total'] = chunk
1950                        plan[x][index]['count'] = epoch
1951                        plan[x][index]['box_time'] = j
1952                        plan[x][index]['box_capital'] = _box[j]['capital']
1953                        plan[x][index]['box_rest'] = _box[j]['rest']
1954                        plan[x][index]['box_last'] = _box[j]['last']
1955                        plan[x][index]['box_total'] = _box[j]['total']
1956                        plan[x][index]['box_count'] = _box[j]['count']
1957                        plan[x][index]['box_log'] = _log[j]['desc']
1958                        plan[x][index]['exchange_rate'] = exchange['rate']
1959                        plan[x][index]['exchange_time'] = exchange['time']
1960                        plan[x][index]['exchange_desc'] = exchange['description']
1961        valid = valid or below_nisab >= nisab
1962        if debug:
1963            print(f"below_nisab({below_nisab}) >= nisab({nisab})")
1964        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:
1966    def build_payment_parts(self, scaled_demand: int, positive_only: bool = True) -> dict:
1967        """
1968        Build payment parts for the Zakat distribution.
1969
1970        Parameters:
1971        scaled_demand (int): The total demand for payment in local currency.
1972        positive_only (bool): If True, only consider accounts with positive balance. Default is True.
1973
1974        Returns:
1975        dict: A dictionary containing the payment parts for each account. The dictionary has the following structure:
1976        {
1977            'account': {
1978                'account_id': {'balance': float, 'rate': float, 'part': float},
1979                ...
1980            },
1981            'exceed': bool,
1982            'demand': int,
1983            'total': float,
1984        }
1985        """
1986        total = 0
1987        parts = {
1988            'account': {},
1989            'exceed': False,
1990            'demand': int(round(scaled_demand)),
1991        }
1992        for x, y in self.accounts().items():
1993            if positive_only and y <= 0:
1994                continue
1995            total += float(y)
1996            exchange = self.exchange(x)
1997            parts['account'][x] = {'balance': y, 'rate': exchange['rate'], 'part': 0}
1998        parts['total'] = total
1999        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:
2001    @staticmethod
2002    def check_payment_parts(parts: dict, debug: bool = False) -> int:
2003        """
2004        Checks the validity of payment parts.
2005
2006        Parameters:
2007        parts (dict): A dictionary containing payment parts information.
2008        debug (bool): Flag to enable debug mode.
2009
2010        Returns:
2011        int: Returns 0 if the payment parts are valid, otherwise returns the error code.
2012
2013        Error Codes:
2014        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
2015        2: 'balance', 'rate' or 'part' key is missing in parts['account'][x].
2016        3: 'part' value in parts['account'][x] is less than 0.
2017        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
2018        5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
2019        6: The sum of 'part' values in parts['account'] does not match with 'demand' value.
2020        """
2021        if debug:
2022            print('check_payment_parts', f'debug={debug}')
2023        for i in ['demand', 'account', 'total', 'exceed']:
2024            if i not in parts:
2025                return 1
2026        exceed = parts['exceed']
2027        for x in parts['account']:
2028            for j in ['balance', 'rate', 'part']:
2029                if j not in parts['account'][x]:
2030                    return 2
2031                if parts['account'][x]['part'] < 0:
2032                    return 3
2033                if not exceed and parts['account'][x]['balance'] <= 0:
2034                    return 4
2035        demand = parts['demand']
2036        z = 0
2037        for _, y in parts['account'].items():
2038            if not exceed and y['part'] > y['balance']:
2039                return 5
2040            z += ZakatTracker.exchange_calc(y['part'], y['rate'], 1)
2041        z = round(z, 2)
2042        demand = round(demand, 2)
2043        if debug:
2044            print('check_payment_parts', f'z = {z}, demand = {demand}')
2045            print('check_payment_parts', type(z), type(demand))
2046            print('check_payment_parts', z != demand)
2047            print('check_payment_parts', str(z) != str(demand))
2048        if z != demand and str(z) != str(demand):
2049            return 6
2050        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:
2052    def zakat(self, report: tuple, parts: Dict[str, Dict | bool | Any] = None, debug: bool = False) -> bool:
2053        """
2054        Perform Zakat calculation based on the given report and optional parts.
2055
2056        Parameters:
2057        report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan.
2058        parts (dict): A dictionary containing the payment parts for the zakat.
2059        debug (bool): A flag indicating whether to print debug information.
2060
2061        Returns:
2062        bool: True if the zakat calculation is successful, False otherwise.
2063        """
2064        if debug:
2065            print('zakat', f'debug={debug}')
2066        valid, _, plan = report
2067        if not valid:
2068            return valid
2069        parts_exist = parts is not None
2070        if parts_exist:
2071            if self.check_payment_parts(parts, debug=debug) != 0:
2072                return False
2073        if debug:
2074            print('######### zakat #######')
2075            print('parts_exist', parts_exist)
2076        no_lock = self.nolock()
2077        self.lock()
2078        report_time = self.time()
2079        self._vault['report'][report_time] = report
2080        self._step(Action.REPORT, ref=report_time)
2081        created = self.time()
2082        for x in plan:
2083            target_exchange = self.exchange(x)
2084            if debug:
2085                print(plan[x])
2086                print('-------------')
2087                print(self._vault['account'][x]['box'])
2088            ids = sorted(self._vault['account'][x]['box'].keys())
2089            if debug:
2090                print('plan[x]', plan[x])
2091            for i in plan[x].keys():
2092                j = ids[i]
2093                if debug:
2094                    print('i', i, 'j', j)
2095                self._step(Action.ZAKAT, account=x, ref=j, value=self._vault['account'][x]['box'][j]['last'],
2096                           key='last',
2097                           math_operation=MathOperation.EQUAL)
2098                self._vault['account'][x]['box'][j]['last'] = created
2099                amount = ZakatTracker.exchange_calc(float(plan[x][i]['total']), 1, float(target_exchange['rate']))
2100                self._vault['account'][x]['box'][j]['total'] += amount
2101                self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='total',
2102                           math_operation=MathOperation.ADDITION)
2103                self._vault['account'][x]['box'][j]['count'] += plan[x][i]['count']
2104                self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['count'], key='count',
2105                           math_operation=MathOperation.ADDITION)
2106                if not parts_exist:
2107                    try:
2108                        self._vault['account'][x]['box'][j]['rest'] -= amount
2109                    except TypeError:
2110                        self._vault['account'][x]['box'][j]['rest'] -= decimal.Decimal(amount)
2111                    # self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest',
2112                    #            math_operation=MathOperation.SUBTRACTION)
2113                    self._log(-float(amount), desc='zakat-زكاة', account=x, created=None, ref=j, debug=debug)
2114        if parts_exist:
2115            for account, part in parts['account'].items():
2116                if part['part'] == 0:
2117                    continue
2118                if debug:
2119                    print('zakat-part', account, part['rate'])
2120                target_exchange = self.exchange(account)
2121                amount = ZakatTracker.exchange_calc(part['part'], part['rate'], target_exchange['rate'])
2122                self.sub(
2123                    unscaled_value=self.unscale(int(amount)),
2124                    desc='zakat-part-دفعة-زكاة',
2125                    account=account,
2126                    debug=debug,
2127                )
2128        if no_lock:
2129            self.free(self.lock())
2130        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:
2132    def export_json(self, path: str = "data.json") -> bool:
2133        """
2134        Exports the current state of the ZakatTracker object to a JSON file.
2135
2136        Parameters:
2137        path (str): The path where the JSON file will be saved. Default is "data.json".
2138
2139        Returns:
2140        bool: True if the export is successful, False otherwise.
2141
2142        Raises:
2143        No specific exceptions are raised by this method.
2144        """
2145        with open(path, "w", encoding="utf-8") as file:
2146            json.dump(self._vault, file, indent=4, cls=JSONEncoder)
2147            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:
2149    def save(self, path: str = None) -> bool:
2150        """
2151        Saves the ZakatTracker's current state to a camel file.
2152
2153        This method serializes the internal data (`_vault`).
2154
2155        Parameters:
2156        path (str, optional): File path for saving. Defaults to a predefined location.
2157
2158        Returns:
2159        bool: True if the save operation is successful, False otherwise.
2160        """
2161        if path is None:
2162            path = self.path()
2163        try:
2164        	# first save in tmp file
2165        	with open(f'{path}.tmp', 'w', encoding="utf-8") as stream:
2166        		stream.write(camel.dump(self._vault))
2167        	# then move tmp file to original location
2168        	shutil.move(f'{path}.tmp', path)
2169        	return True
2170        except (IOError, OSError) as e:
2171            print(f"Error saving file: {e}")
2172            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:
2174    def load(self, path: str = None) -> bool:
2175        """
2176        Load the current state of the ZakatTracker object from a camel file.
2177
2178        Parameters:
2179        path (str): The path where the camel file is located. If not provided, it will use the default path.
2180
2181        Returns:
2182        bool: True if the load operation is successful, False otherwise.
2183        """
2184        if path is None:
2185            path = self.path()
2186        try:
2187            if os.path.exists(path):
2188                with open(path, 'r', encoding="utf-8") as stream:
2189                	self._vault = camel.load(stream.read())
2190                return True
2191            else:
2192            	print(f"File not found: {path}")
2193            	return False
2194        except (IOError, OSError) as e:
2195            print(f"Error loading file: {e}")
2196            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):
2198    def import_csv_cache_path(self):
2199        """
2200        Generates the cache file path for imported CSV data.
2201
2202        This function constructs the file path where cached data from CSV imports
2203        will be stored. The cache file is a camel file (.camel extension) appended
2204        to the base path of the object.
2205
2206        Returns:
2207        str: The full path to the import CSV cache file.
2208
2209        Example:
2210            >>> obj = ZakatTracker('/data/reports')
2211            >>> obj.import_csv_cache_path()
2212            '/data/reports.import_csv.camel'
2213        """
2214        path = str(self.path())
2215        ext = self.ext()
2216        ext_len = len(ext)
2217        if path.endswith(f'.{ext}'):
2218            path = path[:-ext_len - 1]
2219        _, filename = os.path.split(path + f'.import_csv.{ext}')
2220        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:
2222    def import_csv(self, path: str = 'file.csv', scale_decimal_places: int = 0, debug: bool = False) -> tuple:
2223        """
2224        The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system.
2225
2226        Parameters:
2227        path (str): The path to the CSV file. Default is 'file.csv'.
2228        scale_decimal_places (int): The number of decimal places to scale the value. Default is 0.
2229        debug (bool): A flag indicating whether to print debug information.
2230
2231        Returns:
2232        tuple: A tuple containing the number of transactions created, the number of transactions found in the cache,
2233                and a dictionary of bad transactions.
2234
2235        Notes:
2236            * Currency Pair Assumption: This function assumes that the exchange rates stored for each account
2237                                        are appropriate for the currency pairs involved in the conversions.
2238            * The exchange rate for each account is based on the last encountered transaction rate that is not equal
2239                to 1.0 or the previous rate for that account.
2240            * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent
2241              transactions of the same account within the whole imported and existing dataset when doing `check` and
2242              `zakat` operations.
2243
2244        Example Usage:
2245            The CSV file should have the following format, rate is optional per transaction:
2246            account, desc, value, date, rate
2247            For example:
2248            safe-45, "Some text", 34872, 1988-06-30 00:00:00, 1
2249        """
2250        if debug:
2251            print('import_csv', f'debug={debug}')
2252        cache: list[int] = []
2253        try:
2254            with open(self.import_csv_cache_path(), 'r', encoding="utf-8") as stream:
2255                cache = camel.load(stream.read())
2256        except:
2257            pass
2258        date_formats = [
2259            "%Y-%m-%d %H:%M:%S",
2260            "%Y-%m-%dT%H:%M:%S",
2261            "%Y-%m-%dT%H%M%S",
2262            "%Y-%m-%d",
2263        ]
2264        created, found, bad = 0, 0, {}
2265        data: dict[int, list] = {}
2266        with open(path, newline='', encoding="utf-8") as f:
2267            i = 0
2268            for row in csv.reader(f, delimiter=','):
2269                i += 1
2270                hashed = hash(tuple(row))
2271                if hashed in cache:
2272                    found += 1
2273                    continue
2274                account = row[0]
2275                desc = row[1]
2276                value = float(row[2])
2277                rate = 1.0
2278                if row[4:5]:  # Empty list if index is out of range
2279                    rate = float(row[4])
2280                date: int = 0
2281                for time_format in date_formats:
2282                    try:
2283                        date = self.time(datetime.datetime.strptime(row[3], time_format))
2284                        break
2285                    except:
2286                        pass
2287                # TODO: not allowed for negative dates in the future after enhance time functions
2288                if date == 0:
2289                    bad[i] = row + ['invalid date']
2290                if value == 0:
2291                    bad[i] = row + ['invalid value']
2292                    continue
2293                if date not in data:
2294                    data[date] = []
2295                data[date].append((i, account, desc, value, date, rate, hashed))
2296
2297        if debug:
2298            print('import_csv', len(data))
2299
2300        if bad:
2301            return created, found, bad
2302
2303        for date, rows in sorted(data.items()):
2304            try:
2305                len_rows = len(rows)
2306                if len_rows == 1:
2307                    (_, account, desc, unscaled_value, date, rate, hashed) = rows[0]
2308                    value = self.unscale(
2309                        unscaled_value,
2310                        decimal_places=scale_decimal_places,
2311                    ) if scale_decimal_places > 0 else unscaled_value
2312                    if rate > 0:
2313                        self.exchange(account=account, created=date, rate=rate)
2314                    if value > 0:
2315                        self.track(unscaled_value=value, desc=desc, account=account, logging=True, created=date)
2316                    elif value < 0:
2317                        self.sub(unscaled_value=-value, desc=desc, account=account, created=date)
2318                    created += 1
2319                    cache.append(hashed)
2320                    continue
2321                if debug:
2322                    print('-- Duplicated time detected', date, 'len', len_rows)
2323                    print(rows)
2324                    print('---------------------------------')
2325                # If records are found at the same time with different accounts in the same amount
2326                # (one positive and the other negative), this indicates it is a transfer.
2327                if len_rows != 2:
2328                    raise Exception(f'more than two transactions({len_rows}) at the same time')
2329                (i, account1, desc1, unscaled_value1, date1, rate1, _) = rows[0]
2330                (j, account2, desc2, unscaled_value2, date2, rate2, _) = rows[1]
2331                if account1 == account2 or desc1 != desc2 or abs(unscaled_value1) != abs(
2332                        unscaled_value2) or date1 != date2:
2333                    raise Exception('invalid transfer')
2334                if rate1 > 0:
2335                    self.exchange(account1, created=date1, rate=rate1)
2336                if rate2 > 0:
2337                    self.exchange(account2, created=date2, rate=rate2)
2338                value1 = self.unscale(
2339                    unscaled_value1,
2340                    decimal_places=scale_decimal_places,
2341                ) if scale_decimal_places > 0 else unscaled_value1
2342                value2 = self.unscale(
2343                    unscaled_value2,
2344                    decimal_places=scale_decimal_places,
2345                ) if scale_decimal_places > 0 else unscaled_value2
2346                values = {
2347                    value1: account1,
2348                    value2: account2,
2349                }
2350                self.transfer(
2351                    unscaled_amount=abs(value1),
2352                    from_account=values[min(values.keys())],
2353                    to_account=values[max(values.keys())],
2354                    desc=desc1,
2355                    created=date1,
2356                )
2357            except Exception as e:
2358                for (i, account, desc, value, date, rate, _) in rows:
2359                    bad[i] = (account, desc, value, date, rate, e)
2360                break
2361        with open(self.import_csv_cache_path(), 'w', encoding="utf-8") as stream:
2362            stream.write(camel.dump(cache))
2363        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:
2369    @staticmethod
2370    def human_readable_size(size: float, decimal_places: int = 2) -> str:
2371        """
2372        Converts a size in bytes to a human-readable format (e.g., KB, MB, GB).
2373
2374        This function iterates through progressively larger units of information
2375        (B, KB, MB, GB, etc.) and divides the input size until it fits within a
2376        range that can be expressed with a reasonable number before the unit.
2377
2378        Parameters:
2379        size (float): The size in bytes to convert.
2380        decimal_places (int, optional): The number of decimal places to display
2381            in the result. Defaults to 2.
2382
2383        Returns:
2384        str: A string representation of the size in a human-readable format,
2385            rounded to the specified number of decimal places. For example:
2386                - "1.50 KB" (1536 bytes)
2387                - "23.00 MB" (24117248 bytes)
2388                - "1.23 GB" (1325899906 bytes)
2389        """
2390        if type(size) not in (float, int):
2391            raise TypeError("size must be a float or integer")
2392        if type(decimal_places) != int:
2393            raise TypeError("decimal_places must be an integer")
2394        for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']:
2395            if size < 1024.0:
2396                break
2397            size /= 1024.0
2398        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:
2400    @staticmethod
2401    def get_dict_size(obj: dict, seen: set = None) -> float:
2402        """
2403        Recursively calculates the approximate memory size of a dictionary and its contents in bytes.
2404
2405        This function traverses the dictionary structure, accounting for the size of keys, values,
2406        and any nested objects. It handles various data types commonly found in dictionaries
2407        (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case
2408        of circular references.
2409
2410        Parameters:
2411        obj (dict): The dictionary whose size is to be calculated.
2412        seen (set, optional): A set used internally to track visited objects
2413                             and avoid circular references. Defaults to None.
2414
2415        Returns:
2416            float: An approximate size of the dictionary and its contents in bytes.
2417
2418        Note:
2419        - This function is a method of the `ZakatTracker` class and is likely used to
2420          estimate the memory footprint of data structures relevant to Zakat calculations.
2421        - The size calculation is approximate as it relies on `sys.getsizeof()`, which might
2422          not account for all memory overhead depending on the Python implementation.
2423        - Circular references are handled to prevent infinite recursion.
2424        - Basic numeric types (int, float, complex) are assumed to have fixed sizes.
2425        - String sizes are estimated based on character length and encoding.
2426        """
2427        size = 0
2428        if seen is None:
2429            seen = set()
2430
2431        obj_id = id(obj)
2432        if obj_id in seen:
2433            return 0
2434
2435        seen.add(obj_id)
2436        size += sys.getsizeof(obj)
2437
2438        if isinstance(obj, dict):
2439            for k, v in obj.items():
2440                size += ZakatTracker.get_dict_size(k, seen)
2441                size += ZakatTracker.get_dict_size(v, seen)
2442        elif isinstance(obj, (list, tuple, set, frozenset)):
2443            for item in obj:
2444                size += ZakatTracker.get_dict_size(item, seen)
2445        elif isinstance(obj, (int, float, complex)):  # Handle numbers
2446            pass  # Basic numbers have a fixed size, so nothing to add here
2447        elif isinstance(obj, str):  # Handle strings
2448            size += len(obj) * sys.getsizeof(str().encode())  # Size per character in bytes
2449        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:
2451    @staticmethod
2452    def duration_from_nanoseconds(ns: int,
2453                                  show_zeros_in_spoken_time: bool = False,
2454                                  spoken_time_separator=',',
2455                                  millennia: str = 'Millennia',
2456                                  century: str = 'Century',
2457                                  years: str = 'Years',
2458                                  days: str = 'Days',
2459                                  hours: str = 'Hours',
2460                                  minutes: str = 'Minutes',
2461                                  seconds: str = 'Seconds',
2462                                  milli_seconds: str = 'MilliSeconds',
2463                                  micro_seconds: str = 'MicroSeconds',
2464                                  nano_seconds: str = 'NanoSeconds',
2465                                  ) -> tuple:
2466        """
2467        REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
2468        Convert NanoSeconds to Human Readable Time Format.
2469        A NanoSeconds is a unit of time in the International System of Units (SI) equal
2470        to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
2471        Its symbol is μs, sometimes simplified to us when Unicode is not available.
2472        A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
2473
2474        INPUT : ms (AKA: MilliSeconds)
2475        OUTPUT: tuple(string time_lapsed, string spoken_time) like format.
2476        OUTPUT Variables: time_lapsed, spoken_time
2477
2478        Example  Input: duration_from_nanoseconds(ns)
2479        **"Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds"**
2480        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')
2481        duration_from_nanoseconds(1234567890123456789012)
2482        """
2483        us, ns = divmod(ns, 1000)
2484        ms, us = divmod(us, 1000)
2485        s, ms = divmod(ms, 1000)
2486        m, s = divmod(s, 60)
2487        h, m = divmod(m, 60)
2488        d, h = divmod(h, 24)
2489        y, d = divmod(d, 365)
2490        c, y = divmod(y, 100)
2491        n, c = divmod(c, 10)
2492        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}"
2493        spoken_time_part = []
2494        if n > 0 or show_zeros_in_spoken_time:
2495            spoken_time_part.append(f"{n: 3d} {millennia}")
2496        if c > 0 or show_zeros_in_spoken_time:
2497            spoken_time_part.append(f"{c: 4d} {century}")
2498        if y > 0 or show_zeros_in_spoken_time:
2499            spoken_time_part.append(f"{y: 3d} {years}")
2500        if d > 0 or show_zeros_in_spoken_time:
2501            spoken_time_part.append(f"{d: 4d} {days}")
2502        if h > 0 or show_zeros_in_spoken_time:
2503            spoken_time_part.append(f"{h: 2d} {hours}")
2504        if m > 0 or show_zeros_in_spoken_time:
2505            spoken_time_part.append(f"{m: 2d} {minutes}")
2506        if s > 0 or show_zeros_in_spoken_time:
2507            spoken_time_part.append(f"{s: 2d} {seconds}")
2508        if ms > 0 or show_zeros_in_spoken_time:
2509            spoken_time_part.append(f"{ms: 3d} {milli_seconds}")
2510        if us > 0 or show_zeros_in_spoken_time:
2511            spoken_time_part.append(f"{us: 3d} {micro_seconds}")
2512        if ns > 0 or show_zeros_in_spoken_time:
2513            spoken_time_part.append(f"{ns: 3d} {nano_seconds}")
2514        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:
2516    @staticmethod
2517    def day_to_time(day: int, month: int = 6, year: int = 2024) -> int:  # افتراض أن الشهر هو يونيو والسنة 2024
2518        """
2519        Convert a specific day, month, and year into a timestamp.
2520
2521        Parameters:
2522        day (int): The day of the month.
2523        month (int): The month of the year. Default is 6 (June).
2524        year (int): The year. Default is 2024.
2525
2526        Returns:
2527        int: The timestamp representing the given day, month, and year.
2528
2529        Note:
2530        This method assumes the default month and year if not provided.
2531        """
2532        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:
2534    @staticmethod
2535    def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
2536        """
2537        Generate a random date between two given dates.
2538
2539        Parameters:
2540        start_date (datetime.datetime): The start date from which to generate a random date.
2541        end_date (datetime.datetime): The end date until which to generate a random date.
2542
2543        Returns:
2544        datetime.datetime: A random date between the start_date and end_date.
2545        """
2546        time_between_dates = end_date - start_date
2547        days_between_dates = time_between_dates.days
2548        random_number_of_days = random.randrange(days_between_dates)
2549        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:
2551    @staticmethod
2552    def generate_random_csv_file(path: str = "data.csv", count: int = 1000, with_rate: bool = False,
2553                                 debug: bool = False) -> int:
2554        """
2555        Generate a random CSV file with specified parameters.
2556
2557        Parameters:
2558        path (str): The path where the CSV file will be saved. Default is "data.csv".
2559        count (int): The number of rows to generate in the CSV file. Default is 1000.
2560        with_rate (bool): If True, a random rate between 1.2% and 12% is added. Default is False.
2561        debug (bool): A flag indicating whether to print debug information.
2562
2563        Returns:
2564        None. The function generates a CSV file at the specified path with the given count of rows.
2565        Each row contains a randomly generated account, description, value, and date.
2566        The value is randomly generated between 1000 and 100000,
2567        and the date is randomly generated between 1950-01-01 and 2023-12-31.
2568        If the row number is not divisible by 13, the value is multiplied by -1.
2569        """
2570        if debug:
2571            print('generate_random_csv_file', f'debug={debug}')
2572        i = 0
2573        with open(path, "w", newline="", encoding="utf-8") as csvfile:
2574            writer = csv.writer(csvfile)
2575            for i in range(count):
2576                account = f"acc-{random.randint(1, 1000)}"
2577                desc = f"Some text {random.randint(1, 1000)}"
2578                value = random.randint(1000, 100000)
2579                date = ZakatTracker.generate_random_date(datetime.datetime(1000, 1, 1),
2580                                                         datetime.datetime(2023, 12, 31)).strftime("%Y-%m-%d %H:%M:%S")
2581                if not i % 13 == 0:
2582                    value *= -1
2583                row = [account, desc, value, date]
2584                if with_rate:
2585                    rate = random.randint(1, 100) * 0.12
2586                    if debug:
2587                        print('before-append', row)
2588                    row.append(rate)
2589                    if debug:
2590                        print('after-append', row)
2591                writer.writerow(row)
2592                i = i + 1
2593        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):
2595    @staticmethod
2596    def create_random_list(max_sum, min_value=0, max_value=10):
2597        """
2598        Creates a list of random integers whose sum does not exceed the specified maximum.
2599
2600        Args:
2601            max_sum: The maximum allowed sum of the list elements.
2602            min_value: The minimum possible value for an element (inclusive).
2603            max_value: The maximum possible value for an element (inclusive).
2604
2605        Returns:
2606            A list of random integers.
2607        """
2608        result = []
2609        current_sum = 0
2610
2611        while current_sum < max_sum:
2612            # Calculate the remaining space for the next element
2613            remaining_sum = max_sum - current_sum
2614            # Determine the maximum possible value for the next element
2615            next_max_value = min(remaining_sum, max_value)
2616            # Generate a random element within the allowed range
2617            next_element = random.randint(min_value, next_max_value)
2618            result.append(next_element)
2619            current_sum += next_element
2620
2621        return result

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

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

Returns: A list of random integers.

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

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

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

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

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

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

Returns: int: The available TCP port number.

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

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

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