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

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 pickle file as its database to persist the tracker state, ensuring data integrity across sessions. It also provides options for enabling or disabling history tracking, allowing users to choose their preferred level of detail.

In addition, the ZakatTracker class includes various helper methods like time, time_to_datetime, lock, free, recall, export_json, and more. These methods provide additional functionalities and flexibility for interacting with and managing the Zakat tracker.

Attributes: ZakatTracker.ZakatCut (function): A function to calculate the Zakat percentage. ZakatTracker.TimeCycle (function): A function to determine the time cycle for Zakat. ZakatTracker.Nisab (function): A function to calculate the Nisab based on the silver price. ZakatTracker.Version (function): The version of the ZakatTracker class.

Data Structure: The ZakatTracker class utilizes a nested dictionary structure called "_vault" to store and manage data.

_vault (dict):
    - account (dict):
        - {account_number} (dict):
            - balance (int): The current balance of the account.
            - box (dict): A dictionary storing transaction details.
                - {timestamp} (dict):
                    - capital (int): The initial amount of the transaction.
                    - count (int): The number of times Zakat has been calculated for this transaction.
                    - last (int): The timestamp of the last Zakat calculation.
                    - rest (int): The remaining amount after Zakat deductions and withdrawal.
                    - total (int): The total Zakat deducted from this transaction.
            - count (int): The total number of transactions for the account.
            - log (dict): A dictionary storing transaction logs.
                - {timestamp} (dict):
                    - value (int): The transaction amount (positive or negative).
                    - desc (str): The description of the transaction.
                    - ref (int): The box reference (positive or None).
                    - file (dict): A dictionary storing file references associated with the transaction.
            - hide (bool): Indicates whether the account is hidden or not.
            - zakatable (bool): Indicates whether the account is subject to Zakat.
    - exchange (dict):
        - account (dict):
            - {timestamps} (dict):
                - rate (float): Exchange rate when compared to local currency.
                - description (str): The description of the exchange rate.
    - history (dict):
        - {timestamp} (list): A list of dictionaries storing the history of actions performed.
            - {action_dict} (dict):
                - action (Action): The type of action (CREATE, TRACK, LOG, SUB, ADD_FILE, REMOVE_FILE, BOX_TRANSFER, EXCHANGE, REPORT, ZAKAT).
                - account (str): The account number associated with the action.
                - ref (int): The reference number of the transaction.
                - file (int): The reference number of the file (if applicable).
                - key (str): The key associated with the action (e.g., 'rest', 'total').
                - value (int): The value associated with the action.
                - math (MathOperation): The mathematical operation performed (if applicable).
    - lock (int or None): The timestamp indicating the current lock status (None if not locked).
    - report (dict):
        - {timestamp} (tuple): A tuple storing Zakat report details.
ZakatTracker(db_path: str = 'zakat.pickle', history_mode: bool = True)
239    def __init__(self, db_path: str = "zakat.pickle", history_mode: bool = True):
240        """
241        Initialize ZakatTracker with database path and history mode.
242
243        Parameters:
244        db_path (str): The path to the database file. Default is "zakat.pickle".
245        history_mode (bool): The mode for tracking history. Default is True.
246
247        Returns:
248        None
249        """
250        self._vault_path = None
251        self._vault = None
252        self.reset()
253        self._history(history_mode)
254        self.path(db_path)
255        self.load()

Initialize ZakatTracker with database path and history mode.

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

Returns: None

@staticmethod
def Version():
173    @staticmethod
174    def Version():
175        """
176        Returns the current version of the software.
177
178        This function returns a string representing the current version of the software,
179        including major, minor, and patch version numbers in the format "X.Y.Z".
180
181        Returns:
182        str: The current version of the software.
183        """
184        return '0.2.74'

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:
186    @staticmethod
187    def ZakatCut(x: float) -> float:
188        """
189        Calculates the Zakat amount due on an asset.
190
191        This function calculates the zakat amount due on a given asset value over one lunar year.
192        Zakat is an Islamic obligatory alms-giving, calculated as a fixed percentage of an individual's wealth
193        that exceeds a certain threshold (Nisab).
194
195        Parameters:
196        x: The total value of the asset on which Zakat is to be calculated.
197
198        Returns:
199        The amount of Zakat due on the asset, calculated as 2.5% of the asset's value.
200        """
201        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:
203    @staticmethod
204    def TimeCycle(days: int = 355) -> int:
205        """
206        Calculates the approximate duration of a lunar year in nanoseconds.
207
208        This function calculates the approximate duration of a lunar year based on the given number of days.
209        It converts the given number of days into nanoseconds for use in high-precision timing applications.
210
211        Parameters:
212        days: The number of days in a lunar year. Defaults to 355,
213              which is an approximation of the average length of a lunar year.
214
215        Returns:
216        The approximate duration of a lunar year in nanoseconds.
217        """
218        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:
220    @staticmethod
221    def Nisab(gram_price: float, gram_quantity: float = 595) -> float:
222        """
223        Calculate the total value of Nisab (a unit of weight in Islamic jurisprudence) based on the given price per gram.
224
225        This function calculates the Nisab value, which is the minimum threshold of wealth,
226        that makes an individual liable for paying Zakat.
227        The Nisab value is determined by the equivalent value of a specific amount
228        of gold or silver (currently 595 grams in silver) in the local currency.
229
230        Parameters:
231        - gram_price (float): The price per gram of Nisab.
232        - gram_quantity (float): The quantity of grams in a Nisab. Default is 595 grams of silver.
233
234        Returns:
235        - float: The total value of Nisab based on the given price per gram.
236        """
237        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.
def path(self, path: str = None) -> str:
257    def path(self, path: str = None) -> str:
258        """
259        Set or get the database path.
260
261        Parameters:
262        path (str): The path to the database file. If not provided, it returns the current path.
263
264        Returns:
265        str: The current database path.
266        """
267        if path is not None:
268            self._vault_path = path
269        return self._vault_path

Set or get the database path.

Parameters: path (str): The path to the database file. If not provided, it returns the current path.

Returns: str: The current database path.

def reset(self) -> None:
285    def reset(self) -> None:
286        """
287        Reset the internal data structure to its initial state.
288
289        Parameters:
290        None
291
292        Returns:
293        None
294        """
295        self._vault = {
296            'account': {},
297            'exchange': {},
298            'history': {},
299            'lock': None,
300            'report': {},
301        }

Reset the internal data structure to its initial state.

Parameters: None

Returns: None

@staticmethod
def time( now: <module 'datetime' from '/opt/hostedtoolcache/Python/3.12.10/x64/lib/python3.12/datetime.py'> = None) -> int:
303    @staticmethod
304    def time(now: datetime = None) -> int:
305        """
306        Generates a timestamp based on the provided datetime object or the current datetime.
307
308        Parameters:
309        now (datetime, optional): The datetime object to generate the timestamp from.
310        If not provided, the current datetime is used.
311
312        Returns:
313        int: The timestamp in positive nanoseconds since the Unix epoch (January 1, 1970),
314            before 1970 will return in negative until 1000AD.
315        """
316        if now is None:
317            now = datetime.datetime.now()
318        ordinal_day = now.toordinal()
319        ns_in_day = (now - now.replace(hour=0, minute=0, second=0, microsecond=0)).total_seconds() * 10 ** 9
320        return int((ordinal_day - 719_163) * 86_400_000_000_000 + ns_in_day)

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

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

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

@staticmethod
def time_to_datetime( ordinal_ns: int) -> <module 'datetime' from '/opt/hostedtoolcache/Python/3.12.10/x64/lib/python3.12/datetime.py'>:
322    @staticmethod
323    def time_to_datetime(ordinal_ns: int) -> datetime:
324        """
325        Converts an ordinal number (number of days since 1000-01-01) to a datetime object.
326
327        Parameters:
328        ordinal_ns (int): The ordinal number of days since 1000-01-01.
329
330        Returns:
331        datetime: The corresponding datetime object.
332        """
333        ordinal_day = ordinal_ns // 86_400_000_000_000 + 719_163
334        ns_in_day = ordinal_ns % 86_400_000_000_000
335        d = datetime.datetime.fromordinal(ordinal_day)
336        t = datetime.timedelta(seconds=ns_in_day // 10 ** 9)
337        return datetime.datetime.combine(d, datetime.time()) + t

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

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

Returns: datetime: The corresponding datetime object.

def nolock(self) -> bool:
375    def nolock(self) -> bool:
376        """
377        Check if the vault lock is currently not set.
378
379        Returns:
380        bool: True if the vault lock is not set, False otherwise.
381        """
382        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:
384    def lock(self) -> int:
385        """
386        Acquires a lock on the ZakatTracker instance.
387
388        Returns:
389        int: The lock ID. This ID can be used to release the lock later.
390        """
391        return self._step()

Acquires a lock on the ZakatTracker instance.

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

def vault(self) -> dict:
393    def vault(self) -> dict:
394        """
395        Returns a copy of the internal vault dictionary.
396
397        This method is used to retrieve the current state of the ZakatTracker object.
398        It provides a snapshot of the internal data structure, allowing for further
399        processing or analysis.
400
401        Returns:
402        dict: A copy of the internal vault dictionary.
403        """
404        return self._vault.copy()

Returns a copy of the internal vault dictionary.

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

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

def stats(self) -> dict[str, tuple]:
406    def stats(self) -> dict[str, tuple]:
407        """
408        Calculates and returns statistics about the object's data storage.
409
410        This method determines the size of the database file on disk and the
411        size of the data currently held in RAM (likely within a dictionary).
412        Both sizes are reported in bytes and in a human-readable format
413        (e.g., KB, MB).
414
415        Returns:
416        dict[str, tuple]: A dictionary containing the following statistics:
417
418            * 'database': A tuple with two elements:
419                - The database file size in bytes (int).
420                - The database file size in human-readable format (str).
421            * 'ram': A tuple with two elements:
422                - The RAM usage (dictionary size) in bytes (int).
423                - The RAM usage in human-readable format (str).
424
425        Example:
426        >>> stats = my_object.stats()
427        >>> print(stats['database'])
428        (256000, '250.0 KB')
429        >>> print(stats['ram'])
430        (12345, '12.1 KB')
431        """
432        ram_size = self.get_dict_size(self.vault())
433        file_size = os.path.getsize(self.path())
434        return {
435            'database': (file_size, self.human_readable_size(file_size)),
436            'ram': (ram_size, self.human_readable_size(ram_size)),
437        }

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

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

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

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

Example:

>>> stats = my_object.stats()
>>> print(stats['database'])
(256000, '250.0 KB')
>>> print(stats['ram'])
(12345, '12.1 KB')
def steps(self) -> dict:
439    def steps(self) -> dict:
440        """
441        Returns a copy of the history of steps taken in the ZakatTracker.
442
443        The history is a dictionary where each key is a unique identifier for a step,
444        and the corresponding value is a dictionary containing information about the step.
445
446        Returns:
447        dict: A copy of the history of steps taken in the ZakatTracker.
448        """
449        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:
451    def free(self, lock: int, auto_save: bool = True) -> bool:
452        """
453        Releases the lock on the database.
454
455        Parameters:
456        lock (int): The lock ID to be released.
457        auto_save (bool): Whether to automatically save the database after releasing the lock.
458
459        Returns:
460        bool: True if the lock is successfully released and (optionally) saved, False otherwise.
461        """
462        if lock == self._vault['lock']:
463            self._vault['lock'] = None
464            if auto_save:
465                return self.save(self.path())
466            return True
467        return False

Releases the lock on the database.

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

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

def account_exists(self, account) -> bool:
469    def account_exists(self, account) -> bool:
470        """
471        Check if the given account exists in the vault.
472
473        Parameters:
474        account (str): The account number to check.
475
476        Returns:
477        bool: True if the account exists, False otherwise.
478        """
479        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:
481    def box_size(self, account) -> int:
482        """
483        Calculate the size of the box for a specific account.
484
485        Parameters:
486        account (str): The account number for which the box size needs to be calculated.
487
488        Returns:
489        int: The size of the box for the given account. If the account does not exist, -1 is returned.
490        """
491        if self.account_exists(account):
492            return len(self._vault['account'][account]['box'])
493        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:
495    def log_size(self, account) -> int:
496        """
497        Get the size of the log for a specific account.
498
499        Parameters:
500        account (str): The account number for which the log size needs to be calculated.
501
502        Returns:
503        int: The size of the log for the given account. If the account does not exist, -1 is returned.
504        """
505        if self.account_exists(account):
506            return len(self._vault['account'][account]['log'])
507        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.

def recall(self, dry=True, debug=False) -> bool:
509    def recall(self, dry=True, debug=False) -> bool:
510        """
511        Revert the last operation.
512
513        Parameters:
514        dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
515        debug (bool): If True, the function will print debug information. Default is False.
516
517        Returns:
518        bool: True if the operation was successful, False otherwise.
519        """
520        if not self.nolock() or len(self._vault['history']) == 0:
521            return False
522        if len(self._vault['history']) <= 0:
523            return False
524        ref = sorted(self._vault['history'].keys())[-1]
525        if debug:
526            print('recall', ref)
527        memory = self._vault['history'][ref]
528        if debug:
529            print(type(memory), 'memory', memory)
530
531        limit = len(memory) + 1
532        sub_positive_log_negative = 0
533        for i in range(-1, -limit, -1):
534            x = memory[i]
535            if debug:
536                print(type(x), x)
537            match x['action']:
538                case Action.CREATE:
539                    if x['account'] is not None:
540                        if self.account_exists(x['account']):
541                            if debug:
542                                print('account', self._vault['account'][x['account']])
543                            assert len(self._vault['account'][x['account']]['box']) == 0
544                            assert self._vault['account'][x['account']]['balance'] == 0
545                            assert self._vault['account'][x['account']]['count'] == 0
546                            if dry:
547                                continue
548                            del self._vault['account'][x['account']]
549
550                case Action.TRACK:
551                    if x['account'] is not None:
552                        if self.account_exists(x['account']):
553                            if dry:
554                                continue
555                            self._vault['account'][x['account']]['balance'] -= x['value']
556                            self._vault['account'][x['account']]['count'] -= 1
557                            del self._vault['account'][x['account']]['box'][x['ref']]
558
559                case Action.LOG:
560                    if x['account'] is not None:
561                        if self.account_exists(x['account']):
562                            if x['ref'] in self._vault['account'][x['account']]['log']:
563                                if dry:
564                                    continue
565                                if sub_positive_log_negative == -x['value']:
566                                    self._vault['account'][x['account']]['count'] -= 1
567                                    sub_positive_log_negative = 0
568                                box_ref = self._vault['account'][x['account']]['log'][x['ref']]['ref']
569                                if not box_ref is None:
570                                    assert self.box_exists(x['account'], box_ref)
571                                    box_value = self._vault['account'][x['account']]['log'][x['ref']]['value']
572                                    assert box_value < 0
573                                    self._vault['account'][x['account']]['box'][box_ref]['rest'] += -box_value
574                                    self._vault['account'][x['account']]['balance'] += -box_value
575                                    self._vault['account'][x['account']]['count'] -= 1
576                                del self._vault['account'][x['account']]['log'][x['ref']]
577
578                case Action.SUB:
579                    if x['account'] is not None:
580                        if self.account_exists(x['account']):
581                            if x['ref'] in self._vault['account'][x['account']]['box']:
582                                if dry:
583                                    continue
584                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] += x['value']
585                                self._vault['account'][x['account']]['balance'] += x['value']
586                                sub_positive_log_negative = x['value']
587
588                case Action.ADD_FILE:
589                    if x['account'] is not None:
590                        if self.account_exists(x['account']):
591                            if x['ref'] in self._vault['account'][x['account']]['log']:
592                                if x['file'] in self._vault['account'][x['account']]['log'][x['ref']]['file']:
593                                    if dry:
594                                        continue
595                                    del self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']]
596
597                case Action.REMOVE_FILE:
598                    if x['account'] is not None:
599                        if self.account_exists(x['account']):
600                            if x['ref'] in self._vault['account'][x['account']]['log']:
601                                if dry:
602                                    continue
603                                self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']] = x['value']
604
605                case Action.BOX_TRANSFER:
606                    if x['account'] is not None:
607                        if self.account_exists(x['account']):
608                            if x['ref'] in self._vault['account'][x['account']]['box']:
609                                if dry:
610                                    continue
611                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] -= x['value']
612
613                case Action.EXCHANGE:
614                    if x['account'] is not None:
615                        if x['account'] in self._vault['exchange']:
616                            if x['ref'] in self._vault['exchange'][x['account']]:
617                                if dry:
618                                    continue
619                                del self._vault['exchange'][x['account']][x['ref']]
620
621                case Action.REPORT:
622                    if x['ref'] in self._vault['report']:
623                        if dry:
624                            continue
625                        del self._vault['report'][x['ref']]
626
627                case Action.ZAKAT:
628                    if x['account'] is not None:
629                        if self.account_exists(x['account']):
630                            if x['ref'] in self._vault['account'][x['account']]['box']:
631                                if x['key'] in self._vault['account'][x['account']]['box'][x['ref']]:
632                                    if dry:
633                                        continue
634                                    match x['math']:
635                                        case MathOperation.ADDITION:
636                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] -= x[
637                                                'value']
638                                        case MathOperation.EQUAL:
639                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] = x['value']
640                                        case MathOperation.SUBTRACTION:
641                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] += x[
642                                                'value']
643
644        if not dry:
645            del self._vault['history'][ref]
646        return True

Revert the last operation.

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

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

def ref_exists(self, account: str, ref_type: str, ref: int) -> bool:
648    def ref_exists(self, account: str, ref_type: str, ref: int) -> bool:
649        """
650        Check if a specific reference (transaction) exists in the vault for a given account and reference type.
651
652        Parameters:
653        account (str): The account number for which to check the existence of the reference.
654        ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
655        ref (int): The reference (transaction) number to check for existence.
656
657        Returns:
658        bool: True if the reference exists for the given account and reference type, False otherwise.
659        """
660        if account in self._vault['account']:
661            return ref in self._vault['account'][account][ref_type]
662        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:
664    def box_exists(self, account: str, ref: int) -> bool:
665        """
666        Check if a specific box (transaction) exists in the vault for a given account and reference.
667
668        Parameters:
669        - account (str): The account number for which to check the existence of the box.
670        - ref (int): The reference (transaction) number to check for existence.
671
672        Returns:
673        - bool: True if the box exists for the given account and reference, False otherwise.
674        """
675        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, value: float = 0, desc: str = '', account: str = 1, logging: bool = True, created: int = None, debug: bool = False) -> int:
677    def track(self, value: float = 0, desc: str = '', account: str = 1, logging: bool = True, created: int = None,
678              debug: bool = False) -> int:
679        """
680        This function tracks a transaction for a specific account.
681
682        Parameters:
683        value (float): The value of the transaction. Default is 0.
684        desc (str): The description of the transaction. Default is an empty string.
685        account (str): The account for which the transaction is being tracked. Default is '1'.
686        logging (bool): Whether to log the transaction. Default is True.
687        created (int): The timestamp of the transaction. If not provided, it will be generated. Default is None.
688        debug (bool): Whether to print debug information. Default is False.
689
690        Returns:
691        int: The timestamp of the transaction.
692
693        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.
694
695        Raises:
696        ValueError: The log transaction happened again in the same nanosecond time.
697        ValueError: The box transaction happened again in the same nanosecond time.
698        """
699        if debug:
700            print('track', f'debug={debug}')
701        if created is None:
702            created = self.time()
703        no_lock = self.nolock()
704        self.lock()
705        if not self.account_exists(account):
706            if debug:
707                print(f"account {account} created")
708            self._vault['account'][account] = {
709                'balance': 0,
710                'box': {},
711                'count': 0,
712                'log': {},
713                'hide': False,
714                'zakatable': True,
715            }
716            self._step(Action.CREATE, account)
717        if value == 0:
718            if no_lock:
719                self.free(self.lock())
720            return 0
721        if logging:
722            self._log(value=value, desc=desc, account=account, created=created, ref=None, debug=debug)
723        if debug:
724            print('create-box', created)
725        if self.box_exists(account, created):
726            raise ValueError(f"The box transaction happened again in the same nanosecond time({created}).")
727        if debug:
728            print('created-box', created)
729        self._vault['account'][account]['box'][created] = {
730            'capital': value,
731            'count': 0,
732            'last': 0,
733            'rest': value,
734            'total': 0,
735        }
736        self._step(Action.TRACK, account, ref=created, value=value)
737        if no_lock:
738            self.free(self.lock())
739        return created

This function tracks a transaction for a specific account.

Parameters: value (float): 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:
741    def log_exists(self, account: str, ref: int) -> bool:
742        """
743        Checks if a specific transaction log entry exists for a given account.
744
745        Parameters:
746        account (str): The account number associated with the transaction log.
747        ref (int): The reference to the transaction log entry.
748
749        Returns:
750        bool: True if the transaction log entry exists, False otherwise.
751        """
752        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:
798    def exchange(self, account, created: int = None, rate: float = None, description: str = None,
799                 debug: bool = False) -> dict:
800        """
801        This method is used to record or retrieve exchange rates for a specific account.
802
803        Parameters:
804        - account (str): The account number for which the exchange rate is being recorded or retrieved.
805        - created (int): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
806        - rate (float): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
807        - description (str): A description of the exchange rate.
808
809        Returns:
810        - dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found,
811        it returns a dictionary with default values for the rate and description.
812        """
813        if debug:
814            print('exchange', f'debug={debug}')
815        if created is None:
816            created = self.time()
817        no_lock = self.nolock()
818        self.lock()
819        if rate is not None:
820            if rate <= 0:
821                return dict()
822            if account not in self._vault['exchange']:
823                self._vault['exchange'][account] = {}
824            if len(self._vault['exchange'][account]) == 0 and rate <= 1:
825                return {"time": created, "rate": 1, "description": None}
826            self._vault['exchange'][account][created] = {"rate": rate, "description": description}
827            self._step(Action.EXCHANGE, account, ref=created, value=rate)
828            if no_lock:
829                self.free(self.lock())
830            if debug:
831                print("exchange-created-1",
832                      f'account: {account}, created: {created}, rate:{rate}, description:{description}')
833
834        if account in self._vault['exchange']:
835            valid_rates = [(ts, r) for ts, r in self._vault['exchange'][account].items() if ts <= created]
836            if valid_rates:
837                latest_rate = max(valid_rates, key=lambda x: x[0])
838                if debug:
839                    print("exchange-read-1",
840                          f'account: {account}, created: {created}, rate:{rate}, description:{description}',
841                          'latest_rate', latest_rate)
842                result = latest_rate[1]
843                result['time'] = latest_rate[0]
844                return result  # إرجاع قاموس يحتوي على المعدل والوصف
845        if debug:
846            print("exchange-read-0", f'account: {account}, created: {created}, rate:{rate}, description:{description}')
847        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.

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:
849    @staticmethod
850    def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
851        """
852        This function calculates the exchanged amount of a currency.
853
854        Args:
855            x (float): The original amount of the currency.
856            x_rate (float): The exchange rate of the original currency.
857            y_rate (float): The exchange rate of the target currency.
858
859        Returns:
860            float: The exchanged amount of the target currency.
861        """
862        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:
864    def exchanges(self) -> dict:
865        """
866        Retrieve the recorded exchange rates for all accounts.
867
868        Parameters:
869        None
870
871        Returns:
872        dict: A dictionary containing all recorded exchange rates.
873        The keys are account names or numbers, and the values are dictionaries containing the exchange rates.
874        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
875        """
876        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:
878    def accounts(self) -> dict:
879        """
880        Returns a dictionary containing account numbers as keys and their respective balances as values.
881
882        Parameters:
883        None
884
885        Returns:
886        dict: A dictionary where keys are account numbers and values are their respective balances.
887        """
888        result = {}
889        for i in self._vault['account']:
890            result[i] = self._vault['account'][i]['balance']
891        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:
893    def boxes(self, account) -> dict:
894        """
895        Retrieve the boxes (transactions) associated with a specific account.
896
897        Parameters:
898        account (str): The account number for which to retrieve the boxes.
899
900        Returns:
901        dict: A dictionary containing the boxes associated with the given account.
902        If the account does not exist, an empty dictionary is returned.
903        """
904        if self.account_exists(account):
905            return self._vault['account'][account]['box']
906        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:
908    def logs(self, account) -> dict:
909        """
910        Retrieve the logs (transactions) associated with a specific account.
911
912        Parameters:
913        account (str): The account number for which to retrieve the logs.
914
915        Returns:
916        dict: A dictionary containing the logs associated with the given account.
917        If the account does not exist, an empty dictionary is returned.
918        """
919        if self.account_exists(account):
920            return self._vault['account'][account]['log']
921        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 add_file(self, account: str, ref: int, path: str) -> int:
923    def add_file(self, account: str, ref: int, path: str) -> int:
924        """
925        Adds a file reference to a specific transaction log entry in the vault.
926
927        Parameters:
928        account (str): The account number associated with the transaction log.
929        ref (int): The reference to the transaction log entry.
930        path (str): The path of the file to be added.
931
932        Returns:
933        int: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
934        """
935        if self.account_exists(account):
936            if ref in self._vault['account'][account]['log']:
937                file_ref = self.time()
938                self._vault['account'][account]['log'][ref]['file'][file_ref] = path
939                no_lock = self.nolock()
940                self.lock()
941                self._step(Action.ADD_FILE, account, ref=ref, file=file_ref)
942                if no_lock:
943                    self.free(self.lock())
944                return file_ref
945        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:
947    def remove_file(self, account: str, ref: int, file_ref: int) -> bool:
948        """
949        Removes a file reference from a specific transaction log entry in the vault.
950
951        Parameters:
952        account (str): The account number associated with the transaction log.
953        ref (int): The reference to the transaction log entry.
954        file_ref (int): The reference of the file to be removed.
955
956        Returns:
957        bool: True if the file reference is successfully removed, False otherwise.
958        """
959        if self.account_exists(account):
960            if ref in self._vault['account'][account]['log']:
961                if file_ref in self._vault['account'][account]['log'][ref]['file']:
962                    x = self._vault['account'][account]['log'][ref]['file'][file_ref]
963                    del self._vault['account'][account]['log'][ref]['file'][file_ref]
964                    no_lock = self.nolock()
965                    self.lock()
966                    self._step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x)
967                    if no_lock:
968                        self.free(self.lock())
969                    return True
970        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:
972    def balance(self, account: str = 1, cached: bool = True) -> int:
973        """
974        Calculate and return the balance of a specific account.
975
976        Parameters:
977        account (str): The account number. Default is '1'.
978        cached (bool): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
979
980        Returns:
981        int: The balance of the account.
982
983        Note:
984        If cached is True, the function returns the cached balance.
985        If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
986        """
987        if cached:
988            return self._vault['account'][account]['balance']
989        x = 0
990        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:
 992    def hide(self, account, status: bool = None) -> bool:
 993        """
 994        Check or set the hide status of a specific account.
 995
 996        Parameters:
 997        account (str): The account number.
 998        status (bool, optional): The new hide status. If not provided, the function will return the current status.
 999
1000        Returns:
1001        bool: The current or updated hide status of the account.
1002
1003        Raises:
1004        None
1005
1006        Example:
1007        >>> tracker = ZakatTracker()
1008        >>> ref = tracker.track(51, 'desc', 'account1')
1009        >>> tracker.hide('account1')  # Set the hide status of 'account1' to True
1010        False
1011        >>> tracker.hide('account1', True)  # Set the hide status of 'account1' to True
1012        True
1013        >>> tracker.hide('account1')  # Get the hide status of 'account1' by default
1014        True
1015        >>> tracker.hide('account1', False)
1016        False
1017        """
1018        if self.account_exists(account):
1019            if status is None:
1020                return self._vault['account'][account]['hide']
1021            self._vault['account'][account]['hide'] = status
1022            return status
1023        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:
1025    def zakatable(self, account, status: bool = None) -> bool:
1026        """
1027        Check or set the zakatable status of a specific account.
1028
1029        Parameters:
1030        account (str): The account number.
1031        status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
1032
1033        Returns:
1034        bool: The current or updated zakatable status of the account.
1035
1036        Raises:
1037        None
1038
1039        Example:
1040        >>> tracker = ZakatTracker()
1041        >>> ref = tracker.track(51, 'desc', 'account1')
1042        >>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
1043        True
1044        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
1045        True
1046        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
1047        True
1048        >>> tracker.zakatable('account1', False)
1049        False
1050        """
1051        if self.account_exists(account):
1052            if status is None:
1053                return self._vault['account'][account]['zakatable']
1054            self._vault['account'][account]['zakatable'] = status
1055            return status
1056        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, x: float, desc: str = '', account: str = 1, created: int = None, debug: bool = False) -> tuple:
1058    def sub(self, x: float, desc: str = '', account: str = 1, created: int = None, debug: bool = False) -> tuple:
1059        """
1060        Subtracts a specified value from an account's balance.
1061
1062        Parameters:
1063        x (float): The amount to be subtracted.
1064        desc (str): A description for the transaction. Defaults to an empty string.
1065        account (str): The account from which the value will be subtracted. Defaults to '1'.
1066        created (int): The timestamp of the transaction. If not provided, the current timestamp will be used.
1067        debug (bool): A flag indicating whether to print debug information. Defaults to False.
1068
1069        Returns:
1070        tuple: A tuple containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
1071
1072        If the amount to subtract is greater than the account's balance,
1073        the remaining amount will be transferred to a new transaction with a negative value.
1074
1075        Raises:
1076        ValueError: The box transaction happened again in the same nanosecond time.
1077        ValueError: The log transaction happened again in the same nanosecond time.
1078        """
1079        if debug:
1080            print('sub', f'debug={debug}')
1081        if x < 0:
1082            return tuple()
1083        if x == 0:
1084            ref = self.track(x, '', account)
1085            return ref, ref
1086        if created is None:
1087            created = self.time()
1088        no_lock = self.nolock()
1089        self.lock()
1090        self.track(0, '', account)
1091        self._log(value=-x, desc=desc, account=account, created=created, ref=None, debug=debug)
1092        ids = sorted(self._vault['account'][account]['box'].keys())
1093        limit = len(ids) + 1
1094        target = x
1095        if debug:
1096            print('ids', ids)
1097        ages = []
1098        for i in range(-1, -limit, -1):
1099            if target == 0:
1100                break
1101            j = ids[i]
1102            if debug:
1103                print('i', i, 'j', j)
1104            rest = self._vault['account'][account]['box'][j]['rest']
1105            if rest >= target:
1106                self._vault['account'][account]['box'][j]['rest'] -= target
1107                self._step(Action.SUB, account, ref=j, value=target)
1108                ages.append((j, target))
1109                target = 0
1110                break
1111            elif target > rest > 0:
1112                chunk = rest
1113                target -= chunk
1114                self._step(Action.SUB, account, ref=j, value=chunk)
1115                ages.append((j, chunk))
1116                self._vault['account'][account]['box'][j]['rest'] = 0
1117        if target > 0:
1118            self.track(-target, desc, account, False, created)
1119            ages.append((created, target))
1120        if no_lock:
1121            self.free(self.lock())
1122        return created, ages

Subtracts a specified value from an account's balance.

Parameters: x (float): 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, amount: int, from_account: str, to_account: str, desc: str = '', created: int = None, debug: bool = False) -> list[int]:
1124    def transfer(self, amount: int, from_account: str, to_account: str, desc: str = '', created: int = None,
1125                 debug: bool = False) -> list[int]:
1126        """
1127        Transfers a specified value from one account to another.
1128
1129        Parameters:
1130        amount (int): The amount to be transferred.
1131        from_account (str): The account from which the value will be transferred.
1132        to_account (str): The account to which the value will be transferred.
1133        desc (str, optional): A description for the transaction. Defaults to an empty string.
1134        created (int, optional): The timestamp of the transaction. If not provided, the current timestamp will be used.
1135        debug (bool): A flag indicating whether to print debug information. Defaults to False.
1136
1137        Returns:
1138        list[int]: A list of timestamps corresponding to the transactions made during the transfer.
1139
1140        Raises:
1141        ValueError: Transfer to the same account is forbidden.
1142        ValueError: The box transaction happened again in the same nanosecond time.
1143        ValueError: The log transaction happened again in the same nanosecond time.
1144        """
1145        if debug:
1146            print('transfer', f'debug={debug}')
1147        if from_account == to_account:
1148            raise ValueError(f'Transfer to the same account is forbidden. {to_account}')
1149        if amount <= 0:
1150            return []
1151        if created is None:
1152            created = self.time()
1153        (_, ages) = self.sub(amount, desc, from_account, created, debug=debug)
1154        times = []
1155        source_exchange = self.exchange(from_account, created)
1156        target_exchange = self.exchange(to_account, created)
1157
1158        if debug:
1159            print('ages', ages)
1160
1161        for age, value in ages:
1162            target_amount = self.exchange_calc(value, source_exchange['rate'], target_exchange['rate'])
1163            # Perform the transfer
1164            if self.box_exists(to_account, age):
1165                if debug:
1166                    print('box_exists', age)
1167                capital = self._vault['account'][to_account]['box'][age]['capital']
1168                rest = self._vault['account'][to_account]['box'][age]['rest']
1169                if debug:
1170                    print(
1171                        f"Transfer {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1172                selected_age = age
1173                if rest + target_amount > capital:
1174                    self._vault['account'][to_account]['box'][age]['capital'] += target_amount
1175                    selected_age = ZakatTracker.time()
1176                self._vault['account'][to_account]['box'][age]['rest'] += target_amount
1177                self._step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount)
1178                y = self._log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account,
1179                              created=None, ref=None, debug=debug)
1180                times.append((age, y))
1181                continue
1182            y = self.track(target_amount, desc, to_account, logging=True, created=age, debug=debug)
1183            if debug:
1184                print(
1185                    f"Transferred {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1186            times.append(y)
1187        return times

Transfers a specified value from one account to another.

Parameters: amount (int): 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, nisab: float = None, debug: bool = False, now: int = None, cycle: float = None) -> tuple:
1189    def check(self, silver_gram_price: float, nisab: float = None, debug: bool = False, now: int = None,
1190              cycle: float = None) -> tuple:
1191        """
1192        Check the eligibility for Zakat based on the given parameters.
1193
1194        Parameters:
1195        silver_gram_price (float): The price of a gram of silver.
1196        nisab (float): The minimum amount of wealth required for Zakat. If not provided,
1197                        it will be calculated based on the silver_gram_price.
1198        debug (bool): Flag to enable debug mode.
1199        now (int): The current timestamp. If not provided, it will be calculated using ZakatTracker.time().
1200        cycle (float): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
1201
1202        Returns:
1203        tuple: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics,
1204        and a dictionary containing the Zakat plan.
1205        """
1206        if debug:
1207            print('check', f'debug={debug}')
1208        if now is None:
1209            now = self.time()
1210        if cycle is None:
1211            cycle = ZakatTracker.TimeCycle()
1212        if nisab is None:
1213            nisab = ZakatTracker.Nisab(silver_gram_price)
1214        plan = {}
1215        below_nisab = 0
1216        brief = [0, 0, 0]
1217        valid = False
1218        for x in self._vault['account']:
1219            if not self.zakatable(x):
1220                continue
1221            _box = self._vault['account'][x]['box']
1222            _log = self._vault['account'][x]['log']
1223            limit = len(_box) + 1
1224            ids = sorted(self._vault['account'][x]['box'].keys())
1225            for i in range(-1, -limit, -1):
1226                j = ids[i]
1227                rest = float(_box[j]['rest'])
1228                if rest <= 0:
1229                    continue
1230                exchange = self.exchange(x, created=self.time())
1231                if debug:
1232                    print('exchanges', self.exchanges())
1233                rest = ZakatTracker.exchange_calc(rest, exchange['rate'], 1)
1234                brief[0] += rest
1235                index = limit + i - 1
1236                epoch = (now - j) / cycle
1237                if debug:
1238                    print(f"Epoch: {epoch}", _box[j])
1239                if _box[j]['last'] > 0:
1240                    epoch = (now - _box[j]['last']) / cycle
1241                if debug:
1242                    print(f"Epoch: {epoch}")
1243                epoch = floor(epoch)
1244                if debug:
1245                    print(f"Epoch: {epoch}", type(epoch), epoch == 0, 1 - epoch, epoch)
1246                if epoch == 0:
1247                    continue
1248                if debug:
1249                    print("Epoch - PASSED")
1250                brief[1] += rest
1251                if rest >= nisab:
1252                    total = 0
1253                    for _ in range(epoch):
1254                        total += ZakatTracker.ZakatCut(float(rest) - float(total))
1255                    if total > 0:
1256                        if x not in plan:
1257                            plan[x] = {}
1258                        valid = True
1259                        brief[2] += total
1260                        plan[x][index] = {
1261                            'total': total,
1262                            'count': epoch,
1263                            'box_time': j,
1264                            'box_capital': _box[j]['capital'],
1265                            'box_rest': _box[j]['rest'],
1266                            'box_last': _box[j]['last'],
1267                            'box_total': _box[j]['total'],
1268                            'box_count': _box[j]['count'],
1269                            'box_log': _log[j]['desc'],
1270                            'exchange_rate': exchange['rate'],
1271                            'exchange_time': exchange['time'],
1272                            'exchange_desc': exchange['description'],
1273                        }
1274                else:
1275                    chunk = ZakatTracker.ZakatCut(float(rest))
1276                    if chunk > 0:
1277                        if x not in plan:
1278                            plan[x] = {}
1279                        if j not in plan[x].keys():
1280                            plan[x][index] = {}
1281                        below_nisab += rest
1282                        brief[2] += chunk
1283                        plan[x][index]['below_nisab'] = chunk
1284                        plan[x][index]['total'] = chunk
1285                        plan[x][index]['count'] = epoch
1286                        plan[x][index]['box_time'] = j
1287                        plan[x][index]['box_capital'] = _box[j]['capital']
1288                        plan[x][index]['box_rest'] = _box[j]['rest']
1289                        plan[x][index]['box_last'] = _box[j]['last']
1290                        plan[x][index]['box_total'] = _box[j]['total']
1291                        plan[x][index]['box_count'] = _box[j]['count']
1292                        plan[x][index]['box_log'] = _log[j]['desc']
1293                        plan[x][index]['exchange_rate'] = exchange['rate']
1294                        plan[x][index]['exchange_time'] = exchange['time']
1295                        plan[x][index]['exchange_desc'] = exchange['description']
1296        valid = valid or below_nisab >= nisab
1297        if debug:
1298            print(f"below_nisab({below_nisab}) >= nisab({nisab})")
1299        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. nisab (float): 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, demand: float, positive_only: bool = True) -> dict:
1301    def build_payment_parts(self, demand: float, positive_only: bool = True) -> dict:
1302        """
1303        Build payment parts for the Zakat distribution.
1304
1305        Parameters:
1306        demand (float): The total demand for payment in local currency.
1307        positive_only (bool): If True, only consider accounts with positive balance. Default is True.
1308
1309        Returns:
1310        dict: A dictionary containing the payment parts for each account. The dictionary has the following structure:
1311        {
1312            'account': {
1313                'account_id': {'balance': float, 'rate': float, 'part': float},
1314                ...
1315            },
1316            'exceed': bool,
1317            'demand': float,
1318            'total': float,
1319        }
1320        """
1321        total = 0
1322        parts = {
1323            'account': {},
1324            'exceed': False,
1325            'demand': demand,
1326        }
1327        for x, y in self.accounts().items():
1328            if positive_only and y <= 0:
1329                continue
1330            total += float(y)
1331            exchange = self.exchange(x)
1332            parts['account'][x] = {'balance': y, 'rate': exchange['rate'], 'part': 0}
1333        parts['total'] = total
1334        return parts

Build payment parts for the Zakat distribution.

Parameters: demand (float): 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': float, 'total': float, }

@staticmethod
def check_payment_parts(parts: dict, debug: bool = False) -> int:
1336    @staticmethod
1337    def check_payment_parts(parts: dict, debug: bool = False) -> int:
1338        """
1339        Checks the validity of payment parts.
1340
1341        Parameters:
1342        parts (dict): A dictionary containing payment parts information.
1343        debug (bool): Flag to enable debug mode.
1344
1345        Returns:
1346        int: Returns 0 if the payment parts are valid, otherwise returns the error code.
1347
1348        Error Codes:
1349        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
1350        2: 'balance', 'rate' or 'part' key is missing in parts['account'][x].
1351        3: 'part' value in parts['account'][x] is less than 0.
1352        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
1353        5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
1354        6: The sum of 'part' values in parts['account'] does not match with 'demand' value.
1355        """
1356        if debug:
1357            print('check_payment_parts', f'debug={debug}')
1358        for i in ['demand', 'account', 'total', 'exceed']:
1359            if i not in parts:
1360                return 1
1361        exceed = parts['exceed']
1362        for x in parts['account']:
1363            for j in ['balance', 'rate', 'part']:
1364                if j not in parts['account'][x]:
1365                    return 2
1366                if parts['account'][x]['part'] < 0:
1367                    return 3
1368                if not exceed and parts['account'][x]['balance'] <= 0:
1369                    return 4
1370        demand = parts['demand']
1371        z = 0
1372        for _, y in parts['account'].items():
1373            if not exceed and y['part'] > y['balance']:
1374                return 5
1375            z += ZakatTracker.exchange_calc(y['part'], y['rate'], 1)
1376        z = round(z, 2)
1377        demand = round(demand, 2)
1378        if debug:
1379            print('check_payment_parts', f'z = {z}, demand = {demand}')
1380            print('check_payment_parts', type(z), type(demand))
1381            print('check_payment_parts', z != demand)
1382            print('check_payment_parts', str(z) != str(demand))
1383        if z != demand and str(z) != str(demand):
1384            return 6
1385        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:
1387    def zakat(self, report: tuple, parts: Dict[str, Dict | bool | Any] = None, debug: bool = False) -> bool:
1388        """
1389        Perform Zakat calculation based on the given report and optional parts.
1390
1391        Parameters:
1392        report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan.
1393        parts (dict): A dictionary containing the payment parts for the zakat.
1394        debug (bool): A flag indicating whether to print debug information.
1395
1396        Returns:
1397        bool: True if the zakat calculation is successful, False otherwise.
1398        """
1399        if debug:
1400            print('zakat', f'debug={debug}')
1401        valid, _, plan = report
1402        if not valid:
1403            return valid
1404        parts_exist = parts is not None
1405        if parts_exist:
1406            if self.check_payment_parts(parts, debug=debug) != 0:
1407                return False
1408        if debug:
1409            print('######### zakat #######')
1410            print('parts_exist', parts_exist)
1411        no_lock = self.nolock()
1412        self.lock()
1413        report_time = self.time()
1414        self._vault['report'][report_time] = report
1415        self._step(Action.REPORT, ref=report_time)
1416        created = self.time()
1417        for x in plan:
1418            target_exchange = self.exchange(x)
1419            if debug:
1420                print(plan[x])
1421                print('-------------')
1422                print(self._vault['account'][x]['box'])
1423            ids = sorted(self._vault['account'][x]['box'].keys())
1424            if debug:
1425                print('plan[x]', plan[x])
1426            for i in plan[x].keys():
1427                j = ids[i]
1428                if debug:
1429                    print('i', i, 'j', j)
1430                self._step(Action.ZAKAT, account=x, ref=j, value=self._vault['account'][x]['box'][j]['last'],
1431                           key='last',
1432                           math_operation=MathOperation.EQUAL)
1433                self._vault['account'][x]['box'][j]['last'] = created
1434                amount = ZakatTracker.exchange_calc(float(plan[x][i]['total']), 1, float(target_exchange['rate']))
1435                self._vault['account'][x]['box'][j]['total'] += amount
1436                self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='total',
1437                           math_operation=MathOperation.ADDITION)
1438                self._vault['account'][x]['box'][j]['count'] += plan[x][i]['count']
1439                self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['count'], key='count',
1440                           math_operation=MathOperation.ADDITION)
1441                if not parts_exist:
1442                    try:
1443                        self._vault['account'][x]['box'][j]['rest'] -= amount
1444                    except TypeError:
1445                        self._vault['account'][x]['box'][j]['rest'] -= Decimal(amount)
1446                    # self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest',
1447                    #            math_operation=MathOperation.SUBTRACTION)
1448                    self._log(-float(amount), desc='zakat-زكاة', account=x, created=None, ref=j, debug=debug)
1449        if parts_exist:
1450            for account, part in parts['account'].items():
1451                if part['part'] == 0:
1452                    continue
1453                if debug:
1454                    print('zakat-part', account, part['rate'])
1455                target_exchange = self.exchange(account)
1456                amount = ZakatTracker.exchange_calc(part['part'], part['rate'], target_exchange['rate'])
1457                self.sub(amount, desc='zakat-part-دفعة-زكاة', account=account, debug=debug)
1458        if no_lock:
1459            self.free(self.lock())
1460        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:
1462    def export_json(self, path: str = "data.json") -> bool:
1463        """
1464        Exports the current state of the ZakatTracker object to a JSON file.
1465
1466        Parameters:
1467        path (str): The path where the JSON file will be saved. Default is "data.json".
1468
1469        Returns:
1470        bool: True if the export is successful, False otherwise.
1471
1472        Raises:
1473        No specific exceptions are raised by this method.
1474        """
1475        with open(path, "w") as file:
1476            json.dump(self._vault, file, indent=4, cls=JSONEncoder)
1477            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:
1479    def save(self, path: str = None) -> bool:
1480        """
1481        Saves the ZakatTracker's current state to a pickle file.
1482
1483        This method serializes the internal data (`_vault`) along with metadata
1484        (Python version, pickle protocol) for future compatibility.
1485
1486        Parameters:
1487        path (str, optional): File path for saving. Defaults to a predefined location.
1488
1489        Returns:
1490        bool: True if the save operation is successful, False otherwise.
1491        """
1492        if path is None:
1493            path = self.path()
1494        with open(path, "wb") as f:
1495            version = f'{version_info.major}.{version_info.minor}.{version_info.micro}'
1496            pickle_protocol = pickle.HIGHEST_PROTOCOL
1497            data = {
1498                'python_version': version,
1499                'pickle_protocol': pickle_protocol,
1500                'data': self._vault,
1501            }
1502            pickle.dump(data, f, protocol=pickle_protocol)
1503            return True

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

This method serializes the internal data (_vault) along with metadata (Python version, pickle protocol) for future compatibility.

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:
1505    def load(self, path: str = None) -> bool:
1506        """
1507        Load the current state of the ZakatTracker object from a pickle file.
1508
1509        Parameters:
1510        path (str): The path where the pickle file is located. If not provided, it will use the default path.
1511
1512        Returns:
1513        bool: True if the load operation is successful, False otherwise.
1514        """
1515        if path is None:
1516            path = self.path()
1517        if os.path.exists(path):
1518            with open(path, "rb") as f:
1519                data = pickle.load(f)
1520                self._vault = data['data']
1521                return True
1522        return False

Load the current state of the ZakatTracker object from a pickle file.

Parameters: path (str): The path where the pickle 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):
1524    def import_csv_cache_path(self):
1525        """
1526        Generates the cache file path for imported CSV data.
1527
1528        This function constructs the file path where cached data from CSV imports
1529        will be stored. The cache file is a pickle file (.pickle extension) appended
1530        to the base path of the object.
1531
1532        Returns:
1533        str: The full path to the import CSV cache file.
1534
1535        Example:
1536            >>> obj = ZakatTracker('/data/reports')
1537            >>> obj.import_csv_cache_path()
1538            '/data/reports.import_csv.pickle'
1539        """
1540        path = self.path()
1541        if path.endswith(".pickle"):
1542            path = path[:-7]
1543        return path + '.import_csv.pickle'

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 pickle file (.pickle 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.pickle'

def import_csv(self, path: str = 'file.csv', debug: bool = False) -> tuple:
1545    def import_csv(self, path: str = 'file.csv', debug: bool = False) -> tuple:
1546        """
1547        The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system.
1548
1549        Parameters:
1550        path (str): The path to the CSV file. Default is 'file.csv'.
1551        debug (bool): A flag indicating whether to print debug information.
1552
1553        Returns:
1554        tuple: A tuple containing the number of transactions created, the number of transactions found in the cache,
1555                and a dictionary of bad transactions.
1556
1557        Notes:
1558            * Currency Pair Assumption: This function assumes that the exchange rates stored for each account
1559                                        are appropriate for the currency pairs involved in the conversions.
1560            * The exchange rate for each account is based on the last encountered transaction rate that is not equal
1561                to 1.0 or the previous rate for that account.
1562            * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent
1563              transactions of the same account within the whole imported and existing dataset when doing `check` and
1564              `zakat` operations.
1565
1566        Example Usage:
1567            The CSV file should have the following format, rate is optional per transaction:
1568            account, desc, value, date, rate
1569            For example:
1570            safe-45, "Some text", 34872, 1988-06-30 00:00:00, 1
1571        """
1572        if debug:
1573            print('import_csv', f'debug={debug}')
1574        cache: list[int] = []
1575        try:
1576            with open(self.import_csv_cache_path(), "rb") as f:
1577                cache = pickle.load(f)
1578        except:
1579            pass
1580        date_formats = [
1581            "%Y-%m-%d %H:%M:%S",
1582            "%Y-%m-%dT%H:%M:%S",
1583            "%Y-%m-%dT%H%M%S",
1584            "%Y-%m-%d",
1585        ]
1586        created, found, bad = 0, 0, {}
1587        data: dict[int, list] = {}
1588        with open(path, newline='', encoding="utf-8") as f:
1589            i = 0
1590            for row in csv.reader(f, delimiter=','):
1591                i += 1
1592                hashed = hash(tuple(row))
1593                if hashed in cache:
1594                    found += 1
1595                    continue
1596                account = row[0]
1597                desc = row[1]
1598                value = float(row[2])
1599                rate = 1.0
1600                if row[4:5]:  # Empty list if index is out of range
1601                    rate = float(row[4])
1602                date: int = 0
1603                for time_format in date_formats:
1604                    try:
1605                        date = self.time(datetime.datetime.strptime(row[3], time_format))
1606                        break
1607                    except:
1608                        pass
1609                # TODO: not allowed for negative dates
1610                if date == 0 or value == 0:
1611                    bad[i] = row
1612                    continue
1613                if date not in data:
1614                    data[date] = []
1615                # TODO: If duplicated time with different accounts with the same amount it is an indicator of a transfer
1616                data[date].append((date, value, desc, account, rate, hashed))
1617
1618        if debug:
1619            print('import_csv', len(data))
1620
1621        def process(row, index=0):
1622            nonlocal created
1623            (date, value, desc, account, rate, hashed) = row
1624            date += index
1625            if rate > 1:
1626                self.exchange(account, created=date, rate=rate)
1627            if value > 0:
1628                self.track(value, desc, account, True, date)
1629            elif value < 0:
1630                self.sub(-value, desc, account, date)
1631            created += 1
1632            cache.append(hashed)
1633
1634        for date, rows in sorted(data.items()):
1635            len_rows = len(rows)
1636            if len_rows == 1:
1637                process(rows[0])
1638                continue
1639            if debug:
1640                print('-- Duplicated time detected', date, 'len', len_rows)
1641                print(rows)
1642                print('---------------------------------')
1643            for index, row in enumerate(rows):
1644                process(row, index)
1645        with open(self.import_csv_cache_path(), "wb") as f:
1646            pickle.dump(cache, f)
1647        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'. 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:
1653    @staticmethod
1654    def human_readable_size(size: float, decimal_places: int = 2) -> str:
1655        """
1656        Converts a size in bytes to a human-readable format (e.g., KB, MB, GB).
1657
1658        This function iterates through progressively larger units of information
1659        (B, KB, MB, GB, etc.) and divides the input size until it fits within a
1660        range that can be expressed with a reasonable number before the unit.
1661
1662        Parameters:
1663        size (float): The size in bytes to convert.
1664        decimal_places (int, optional): The number of decimal places to display
1665            in the result. Defaults to 2.
1666
1667        Returns:
1668        str: A string representation of the size in a human-readable format,
1669            rounded to the specified number of decimal places. For example:
1670                - "1.50 KB" (1536 bytes)
1671                - "23.00 MB" (24117248 bytes)
1672                - "1.23 GB" (1325899906 bytes)
1673        """
1674        if type(size) not in (float, int):
1675            raise TypeError("size must be a float or integer")
1676        if type(decimal_places) != int:
1677            raise TypeError("decimal_places must be an integer")
1678        for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']:
1679            if size < 1024.0:
1680                break
1681            size /= 1024.0
1682        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:
1684    @staticmethod
1685    def get_dict_size(obj: dict, seen: set = None) -> float:
1686        """
1687        Recursively calculates the approximate memory size of a dictionary and its contents in bytes.
1688
1689        This function traverses the dictionary structure, accounting for the size of keys, values,
1690        and any nested objects. It handles various data types commonly found in dictionaries
1691        (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case
1692        of circular references.
1693
1694        Parameters:
1695        obj (dict): The dictionary whose size is to be calculated.
1696        seen (set, optional): A set used internally to track visited objects
1697                             and avoid circular references. Defaults to None.
1698
1699        Returns:
1700            float: An approximate size of the dictionary and its contents in bytes.
1701
1702        Note:
1703        - This function is a method of the `ZakatTracker` class and is likely used to
1704          estimate the memory footprint of data structures relevant to Zakat calculations.
1705        - The size calculation is approximate as it relies on `sys.getsizeof()`, which might
1706          not account for all memory overhead depending on the Python implementation.
1707        - Circular references are handled to prevent infinite recursion.
1708        - Basic numeric types (int, float, complex) are assumed to have fixed sizes.
1709        - String sizes are estimated based on character length and encoding.
1710        """
1711        size = 0
1712        if seen is None:
1713            seen = set()
1714
1715        obj_id = id(obj)
1716        if obj_id in seen:
1717            return 0
1718
1719        seen.add(obj_id)
1720        size += sys.getsizeof(obj)
1721
1722        if isinstance(obj, dict):
1723            for k, v in obj.items():
1724                size += ZakatTracker.get_dict_size(k, seen)
1725                size += ZakatTracker.get_dict_size(v, seen)
1726        elif isinstance(obj, (list, tuple, set, frozenset)):
1727            for item in obj:
1728                size += ZakatTracker.get_dict_size(item, seen)
1729        elif isinstance(obj, (int, float, complex)):  # Handle numbers
1730            pass  # Basic numbers have a fixed size, so nothing to add here
1731        elif isinstance(obj, str):  # Handle strings
1732            size += len(obj) * sys.getsizeof(str().encode())  # Size per character in bytes
1733        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:
1735    @staticmethod
1736    def duration_from_nanoseconds(ns: int,
1737                                  show_zeros_in_spoken_time: bool = False,
1738                                  spoken_time_separator=',',
1739                                  millennia: str = 'Millennia',
1740                                  century: str = 'Century',
1741                                  years: str = 'Years',
1742                                  days: str = 'Days',
1743                                  hours: str = 'Hours',
1744                                  minutes: str = 'Minutes',
1745                                  seconds: str = 'Seconds',
1746                                  milli_seconds: str = 'MilliSeconds',
1747                                  micro_seconds: str = 'MicroSeconds',
1748                                  nano_seconds: str = 'NanoSeconds',
1749                                  ) -> tuple:
1750        """
1751        REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
1752        Convert NanoSeconds to Human Readable Time Format.
1753        A NanoSeconds is a unit of time in the International System of Units (SI) equal
1754        to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
1755        Its symbol is μs, sometimes simplified to us when Unicode is not available.
1756        A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
1757
1758        INPUT : ms (AKA: MilliSeconds)
1759        OUTPUT: tuple(string time_lapsed, string spoken_time) like format.
1760        OUTPUT Variables: time_lapsed, spoken_time
1761
1762        Example  Input: duration_from_nanoseconds(ns)
1763        **"Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds"**
1764        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')
1765        duration_from_nanoseconds(1234567890123456789012)
1766        """
1767        us, ns = divmod(ns, 1000)
1768        ms, us = divmod(us, 1000)
1769        s, ms = divmod(ms, 1000)
1770        m, s = divmod(s, 60)
1771        h, m = divmod(m, 60)
1772        d, h = divmod(h, 24)
1773        y, d = divmod(d, 365)
1774        c, y = divmod(y, 100)
1775        n, c = divmod(c, 10)
1776        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}"
1777        spoken_time_part = []
1778        if n > 0 or show_zeros_in_spoken_time:
1779            spoken_time_part.append(f"{n: 3d} {millennia}")
1780        if c > 0 or show_zeros_in_spoken_time:
1781            spoken_time_part.append(f"{c: 4d} {century}")
1782        if y > 0 or show_zeros_in_spoken_time:
1783            spoken_time_part.append(f"{y: 3d} {years}")
1784        if d > 0 or show_zeros_in_spoken_time:
1785            spoken_time_part.append(f"{d: 4d} {days}")
1786        if h > 0 or show_zeros_in_spoken_time:
1787            spoken_time_part.append(f"{h: 2d} {hours}")
1788        if m > 0 or show_zeros_in_spoken_time:
1789            spoken_time_part.append(f"{m: 2d} {minutes}")
1790        if s > 0 or show_zeros_in_spoken_time:
1791            spoken_time_part.append(f"{s: 2d} {seconds}")
1792        if ms > 0 or show_zeros_in_spoken_time:
1793            spoken_time_part.append(f"{ms: 3d} {milli_seconds}")
1794        if us > 0 or show_zeros_in_spoken_time:
1795            spoken_time_part.append(f"{us: 3d} {micro_seconds}")
1796        if ns > 0 or show_zeros_in_spoken_time:
1797            spoken_time_part.append(f"{ns: 3d} {nano_seconds}")
1798        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:
1800    @staticmethod
1801    def day_to_time(day: int, month: int = 6, year: int = 2024) -> int:  # افتراض أن الشهر هو يونيو والسنة 2024
1802        """
1803        Convert a specific day, month, and year into a timestamp.
1804
1805        Parameters:
1806        day (int): The day of the month.
1807        month (int): The month of the year. Default is 6 (June).
1808        year (int): The year. Default is 2024.
1809
1810        Returns:
1811        int: The timestamp representing the given day, month, and year.
1812
1813        Note:
1814        This method assumes the default month and year if not provided.
1815        """
1816        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:
1818    @staticmethod
1819    def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
1820        """
1821        Generate a random date between two given dates.
1822
1823        Parameters:
1824        start_date (datetime.datetime): The start date from which to generate a random date.
1825        end_date (datetime.datetime): The end date until which to generate a random date.
1826
1827        Returns:
1828        datetime.datetime: A random date between the start_date and end_date.
1829        """
1830        time_between_dates = end_date - start_date
1831        days_between_dates = time_between_dates.days
1832        random_number_of_days = random.randrange(days_between_dates)
1833        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:
1835    @staticmethod
1836    def generate_random_csv_file(path: str = "data.csv", count: int = 1000, with_rate: bool = False,
1837                                 debug: bool = False) -> int:
1838        """
1839        Generate a random CSV file with specified parameters.
1840
1841        Parameters:
1842        path (str): The path where the CSV file will be saved. Default is "data.csv".
1843        count (int): The number of rows to generate in the CSV file. Default is 1000.
1844        with_rate (bool): If True, a random rate between 1.2% and 12% is added. Default is False.
1845        debug (bool): A flag indicating whether to print debug information.
1846
1847        Returns:
1848        None. The function generates a CSV file at the specified path with the given count of rows.
1849        Each row contains a randomly generated account, description, value, and date.
1850        The value is randomly generated between 1000 and 100000,
1851        and the date is randomly generated between 1950-01-01 and 2023-12-31.
1852        If the row number is not divisible by 13, the value is multiplied by -1.
1853        """
1854        if debug:
1855            print('generate_random_csv_file', f'debug={debug}')
1856        i = 0
1857        with open(path, "w", newline="") as csvfile:
1858            writer = csv.writer(csvfile)
1859            for i in range(count):
1860                account = f"acc-{random.randint(1, 1000)}"
1861                desc = f"Some text {random.randint(1, 1000)}"
1862                value = random.randint(1000, 100000)
1863                date = ZakatTracker.generate_random_date(datetime.datetime(1950, 1, 1),
1864                                                         datetime.datetime(2023, 12, 31)).strftime("%Y-%m-%d %H:%M:%S")
1865                if not i % 13 == 0:
1866                    value *= -1
1867                row = [account, desc, value, date]
1868                if with_rate:
1869                    rate = random.randint(1, 100) * 0.12
1870                    if debug:
1871                        print('before-append', row)
1872                    row.append(rate)
1873                    if debug:
1874                        print('after-append', row)
1875                writer.writerow(row)
1876                i = i + 1
1877        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):
1879    @staticmethod
1880    def create_random_list(max_sum, min_value=0, max_value=10):
1881        """
1882        Creates a list of random integers whose sum does not exceed the specified maximum.
1883
1884        Args:
1885            max_sum: The maximum allowed sum of the list elements.
1886            min_value: The minimum possible value for an element (inclusive).
1887            max_value: The maximum possible value for an element (inclusive).
1888
1889        Returns:
1890            A list of random integers.
1891        """
1892        result = []
1893        current_sum = 0
1894
1895        while current_sum < max_sum:
1896            # Calculate the remaining space for the next element
1897            remaining_sum = max_sum - current_sum
1898            # Determine the maximum possible value for the next element
1899            next_max_value = min(remaining_sum, max_value)
1900            # Generate a random element within the allowed range
1901            next_element = random.randint(min_value, next_max_value)
1902            result.append(next_element)
1903            current_sum += next_element
1904
1905        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:
2088    def test(self, debug: bool = False) -> bool:
2089        if debug:
2090            print('test', f'debug={debug}')
2091        try:
2092
2093            assert self._history()
2094
2095            # Not allowed for duplicate transactions in the same account and time
2096
2097            created = ZakatTracker.time()
2098            self.track(100, 'test-1', 'same', True, created)
2099            failed = False
2100            try:
2101                self.track(50, 'test-1', 'same', True, created)
2102            except:
2103                failed = True
2104            assert failed is True
2105
2106            self.reset()
2107
2108            # Same account transfer
2109            for x in [1, 'a', True, 1.8, None]:
2110                failed = False
2111                try:
2112                    self.transfer(1, x, x, 'same-account', debug=debug)
2113                except:
2114                    failed = True
2115                assert failed is True
2116
2117            # Always preserve box age during transfer
2118
2119            series: list[tuple] = [
2120                (30, 4),
2121                (60, 3),
2122                (90, 2),
2123            ]
2124            case = {
2125                30: {
2126                    'series': series,
2127                    'rest': 150,
2128                },
2129                60: {
2130                    'series': series,
2131                    'rest': 120,
2132                },
2133                90: {
2134                    'series': series,
2135                    'rest': 90,
2136                },
2137                180: {
2138                    'series': series,
2139                    'rest': 0,
2140                },
2141                270: {
2142                    'series': series,
2143                    'rest': -90,
2144                },
2145                360: {
2146                    'series': series,
2147                    'rest': -180,
2148                },
2149            }
2150
2151            selected_time = ZakatTracker.time() - ZakatTracker.TimeCycle()
2152
2153            for total in case:
2154                for x in case[total]['series']:
2155                    self.track(x[0], f"test-{x} ages", 'ages', True, selected_time * x[1])
2156
2157                refs = self.transfer(total, 'ages', 'future', 'Zakat Movement', debug=debug)
2158
2159                if debug:
2160                    print('refs', refs)
2161
2162                ages_cache_balance = self.balance('ages')
2163                ages_fresh_balance = self.balance('ages', False)
2164                rest = case[total]['rest']
2165                if debug:
2166                    print('source', ages_cache_balance, ages_fresh_balance, rest)
2167                assert ages_cache_balance == rest
2168                assert ages_fresh_balance == rest
2169
2170                future_cache_balance = self.balance('future')
2171                future_fresh_balance = self.balance('future', False)
2172                if debug:
2173                    print('target', future_cache_balance, future_fresh_balance, total)
2174                    print('refs', refs)
2175                assert future_cache_balance == total
2176                assert future_fresh_balance == total
2177
2178                for ref in self._vault['account']['ages']['box']:
2179                    ages_capital = self._vault['account']['ages']['box'][ref]['capital']
2180                    ages_rest = self._vault['account']['ages']['box'][ref]['rest']
2181                    future_capital = 0
2182                    if ref in self._vault['account']['future']['box']:
2183                        future_capital = self._vault['account']['future']['box'][ref]['capital']
2184                    future_rest = 0
2185                    if ref in self._vault['account']['future']['box']:
2186                        future_rest = self._vault['account']['future']['box'][ref]['rest']
2187                    if ages_capital != 0 and future_capital != 0 and future_rest != 0:
2188                        if debug:
2189                            print('================================================================')
2190                            print('ages', ages_capital, ages_rest)
2191                            print('future', future_capital, future_rest)
2192                        if ages_rest == 0:
2193                            assert ages_capital == future_capital
2194                        elif ages_rest < 0:
2195                            assert -ages_capital == future_capital
2196                        elif ages_rest > 0:
2197                            assert ages_capital == ages_rest + future_capital
2198                self.reset()
2199                assert len(self._vault['history']) == 0
2200
2201            assert self._history()
2202            assert self._history(False) is False
2203            assert self._history() is False
2204            assert self._history(True)
2205            assert self._history()
2206
2207            self._test_core(True, debug)
2208            self._test_core(False, debug)
2209
2210            transaction = [
2211                (
2212                    20, 'wallet', 1, 800, 800, 800, 4, 5,
2213                    -85, -85, -85, 6, 7,
2214                ),
2215                (
2216                    750, 'wallet', 'safe', 50, 50, 50, 4, 6,
2217                    750, 750, 750, 1, 1,
2218                ),
2219                (
2220                    600, 'safe', 'bank', 150, 150, 150, 1, 2,
2221                    600, 600, 600, 1, 1,
2222                ),
2223            ]
2224            for z in transaction:
2225                self.lock()
2226                x = z[1]
2227                y = z[2]
2228                self.transfer(z[0], x, y, 'test-transfer', debug=debug)
2229                assert self.balance(x) == z[3]
2230                xx = self.accounts()[x]
2231                assert xx == z[3]
2232                assert self.balance(x, False) == z[4]
2233                assert xx == z[4]
2234
2235                s = 0
2236                log = self._vault['account'][x]['log']
2237                for i in log:
2238                    s += log[i]['value']
2239                if debug:
2240                    print('s', s, 'z[5]', z[5])
2241                assert s == z[5]
2242
2243                assert self.box_size(x) == z[6]
2244                assert self.log_size(x) == z[7]
2245
2246                yy = self.accounts()[y]
2247                assert self.balance(y) == z[8]
2248                assert yy == z[8]
2249                assert self.balance(y, False) == z[9]
2250                assert yy == z[9]
2251
2252                s = 0
2253                log = self._vault['account'][y]['log']
2254                for i in log:
2255                    s += log[i]['value']
2256                assert s == z[10]
2257
2258                assert self.box_size(y) == z[11]
2259                assert self.log_size(y) == z[12]
2260
2261            if debug:
2262                pp().pprint(self.check(2.17))
2263
2264            assert not self.nolock()
2265            history_count = len(self._vault['history'])
2266            if debug:
2267                print('history-count', history_count)
2268            assert history_count == 11
2269            assert not self.free(ZakatTracker.time())
2270            assert self.free(self.lock())
2271            assert self.nolock()
2272            assert len(self._vault['history']) == 11
2273
2274            # storage
2275
2276            _path = self.path('test.pickle')
2277            if os.path.exists(_path):
2278                os.remove(_path)
2279            self.save()
2280            assert os.path.getsize(_path) > 0
2281            self.reset()
2282            assert self.recall(False, debug) is False
2283            self.load()
2284            assert self._vault['account'] is not None
2285
2286            # recall
2287
2288            assert self.nolock()
2289            assert len(self._vault['history']) == 11
2290            assert self.recall(False, debug) is True
2291            assert len(self._vault['history']) == 10
2292            assert self.recall(False, debug) is True
2293            assert len(self._vault['history']) == 9
2294
2295            # exchange
2296
2297            self.exchange("cash", 25, 3.75, "2024-06-25")
2298            self.exchange("cash", 22, 3.73, "2024-06-22")
2299            self.exchange("cash", 15, 3.69, "2024-06-15")
2300            self.exchange("cash", 10, 3.66)
2301
2302            for i in range(1, 30):
2303                exchange = self.exchange("cash", i)
2304                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2305                if debug:
2306                    print(i, rate, description, created)
2307                assert created
2308                if i < 10:
2309                    assert rate == 1
2310                    assert description is None
2311                elif i == 10:
2312                    assert rate == 3.66
2313                    assert description is None
2314                elif i < 15:
2315                    assert rate == 3.66
2316                    assert description is None
2317                elif i == 15:
2318                    assert rate == 3.69
2319                    assert description is not None
2320                elif i < 22:
2321                    assert rate == 3.69
2322                    assert description is not None
2323                elif i == 22:
2324                    assert rate == 3.73
2325                    assert description is not None
2326                elif i >= 25:
2327                    assert rate == 3.75
2328                    assert description is not None
2329                exchange = self.exchange("bank", i)
2330                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2331                if debug:
2332                    print(i, rate, description, created)
2333                assert created
2334                assert rate == 1
2335                assert description is None
2336
2337            assert len(self._vault['exchange']) > 0
2338            assert len(self.exchanges()) > 0
2339            self._vault['exchange'].clear()
2340            assert len(self._vault['exchange']) == 0
2341            assert len(self.exchanges()) == 0
2342
2343            # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية
2344            self.exchange("cash", ZakatTracker.day_to_time(25), 3.75, "2024-06-25")
2345            self.exchange("cash", ZakatTracker.day_to_time(22), 3.73, "2024-06-22")
2346            self.exchange("cash", ZakatTracker.day_to_time(15), 3.69, "2024-06-15")
2347            self.exchange("cash", ZakatTracker.day_to_time(10), 3.66)
2348
2349            for i in [x * 0.12 for x in range(-15, 21)]:
2350                if i <= 0:
2351                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) == 0
2352                else:
2353                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) > 0
2354
2355            # اختبار النتائج باستخدام التواريخ بالنانو ثانية
2356            for i in range(1, 31):
2357                timestamp_ns = ZakatTracker.day_to_time(i)
2358                exchange = self.exchange("cash", timestamp_ns)
2359                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2360                if debug:
2361                    print(i, rate, description, created)
2362                assert created
2363                if i < 10:
2364                    assert rate == 1
2365                    assert description is None
2366                elif i == 10:
2367                    assert rate == 3.66
2368                    assert description is None
2369                elif i < 15:
2370                    assert rate == 3.66
2371                    assert description is None
2372                elif i == 15:
2373                    assert rate == 3.69
2374                    assert description is not None
2375                elif i < 22:
2376                    assert rate == 3.69
2377                    assert description is not None
2378                elif i == 22:
2379                    assert rate == 3.73
2380                    assert description is not None
2381                elif i >= 25:
2382                    assert rate == 3.75
2383                    assert description is not None
2384                exchange = self.exchange("bank", i)
2385                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2386                if debug:
2387                    print(i, rate, description, created)
2388                assert created
2389                assert rate == 1
2390                assert description is None
2391
2392            # csv
2393
2394            csv_count = 1000
2395
2396            for with_rate, path in {
2397                False: 'test-import_csv-no-exchange',
2398                True: 'test-import_csv-with-exchange',
2399            }.items():
2400
2401                if debug:
2402                    print('test_import_csv', with_rate, path)
2403
2404                # csv
2405
2406                csv_path = path + '.csv'
2407                if os.path.exists(csv_path):
2408                    os.remove(csv_path)
2409                c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug)
2410                if debug:
2411                    print('generate_random_csv_file', c)
2412                assert c == csv_count
2413                assert os.path.getsize(csv_path) > 0
2414                cache_path = self.import_csv_cache_path()
2415                if os.path.exists(cache_path):
2416                    os.remove(cache_path)
2417                self.reset()
2418                (created, found, bad) = self.import_csv(csv_path, debug)
2419                bad_count = len(bad)
2420                if debug:
2421                    print(f"csv-imported: ({created}, {found}, {bad_count}) = count({csv_count})")
2422                tmp_size = os.path.getsize(cache_path)
2423                assert tmp_size > 0
2424                assert created + found + bad_count == csv_count
2425                assert created == csv_count
2426                assert bad_count == 0
2427                (created_2, found_2, bad_2) = self.import_csv(csv_path)
2428                bad_2_count = len(bad_2)
2429                if debug:
2430                    print(f"csv-imported: ({created_2}, {found_2}, {bad_2_count})")
2431                    print(bad)
2432                assert tmp_size == os.path.getsize(cache_path)
2433                assert created_2 + found_2 + bad_2_count == csv_count
2434                assert created == found_2
2435                assert bad_count == bad_2_count
2436                assert found_2 == csv_count
2437                assert bad_2_count == 0
2438                assert created_2 == 0
2439
2440                # payment parts
2441
2442                positive_parts = self.build_payment_parts(100, positive_only=True)
2443                assert self.check_payment_parts(positive_parts) != 0
2444                assert self.check_payment_parts(positive_parts) != 0
2445                all_parts = self.build_payment_parts(300, positive_only=False)
2446                assert self.check_payment_parts(all_parts) != 0
2447                assert self.check_payment_parts(all_parts) != 0
2448                if debug:
2449                    pp().pprint(positive_parts)
2450                    pp().pprint(all_parts)
2451                # dynamic discount
2452                suite = []
2453                count = 3
2454                for exceed in [False, True]:
2455                    case = []
2456                    for parts in [positive_parts, all_parts]:
2457                        part = parts.copy()
2458                        demand = part['demand']
2459                        if debug:
2460                            print(demand, part['total'])
2461                        i = 0
2462                        z = demand / count
2463                        cp = {
2464                            'account': {},
2465                            'demand': demand,
2466                            'exceed': exceed,
2467                            'total': part['total'],
2468                        }
2469                        j = ''
2470                        for x, y in part['account'].items():
2471                            x_exchange = self.exchange(x)
2472                            zz = self.exchange_calc(z, 1, x_exchange['rate'])
2473                            if exceed and zz <= demand:
2474                                i += 1
2475                                y['part'] = zz
2476                                if debug:
2477                                    print(exceed, y)
2478                                cp['account'][x] = y
2479                                case.append(y)
2480                            elif not exceed and y['balance'] >= zz:
2481                                i += 1
2482                                y['part'] = zz
2483                                if debug:
2484                                    print(exceed, y)
2485                                cp['account'][x] = y
2486                                case.append(y)
2487                            j = x
2488                            if i >= count:
2489                                break
2490                        if len(cp['account'][j]) > 0:
2491                            suite.append(cp)
2492                if debug:
2493                    print('suite', len(suite))
2494                # vault = self._vault.copy()
2495                for case in suite:
2496                    # self._vault = vault.copy()
2497                    if debug:
2498                        print('case', case)
2499                    result = self.check_payment_parts(case)
2500                    if debug:
2501                        print('check_payment_parts', result, f'exceed: {exceed}')
2502                    assert result == 0
2503
2504                    report = self.check(2.17, None, debug)
2505                    (valid, brief, plan) = report
2506                    if debug:
2507                        print('valid', valid)
2508                    zakat_result = self.zakat(report, parts=case, debug=debug)
2509                    if debug:
2510                        print('zakat-result', zakat_result)
2511                    assert valid == zakat_result
2512
2513            assert self.save(path + '.pickle')
2514            assert self.export_json(path + '.json')
2515
2516            assert self.export_json("1000-transactions-test.json")
2517            assert self.save("1000-transactions-test.pickle")
2518
2519            self.reset()
2520
2521            # test transfer between accounts with different exchange rate
2522
2523            a_SAR = "Bank (SAR)"
2524            b_USD = "Bank (USD)"
2525            c_SAR = "Safe (SAR)"
2526            # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer
2527            for case in [
2528                (0, a_SAR, "SAR Gift", 1000, 1000),
2529                (1, a_SAR, 1),
2530                (0, b_USD, "USD Gift", 500, 500),
2531                (1, b_USD, 1),
2532                (2, b_USD, 3.75),
2533                (1, b_USD, 3.75),
2534                (3, 100, b_USD, a_SAR, "100 USD -> SAR", 400, 1375),
2535                (0, c_SAR, "Salary", 750, 750),
2536                (3, 375, c_SAR, b_USD, "375 SAR -> USD", 375, 500),
2537                (3, 3.75, a_SAR, b_USD, "3.75 SAR -> USD", 1371.25, 501),
2538            ]:
2539                match (case[0]):
2540                    case 0:  # track
2541                        _, account, desc, x, balance = case
2542                        self.track(value=x, desc=desc, account=account, debug=debug)
2543
2544                        cached_value = self.balance(account, cached=True)
2545                        fresh_value = self.balance(account, cached=False)
2546                        if debug:
2547                            print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value)
2548                        assert cached_value == balance
2549                        assert fresh_value == balance
2550                    case 1:  # check-exchange
2551                        _, account, expected_rate = case
2552                        t_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
2553                        if debug:
2554                            print('t-exchange', t_exchange)
2555                        assert t_exchange['rate'] == expected_rate
2556                    case 2:  # do-exchange
2557                        _, account, rate = case
2558                        self.exchange(account, rate=rate, debug=debug)
2559                        b_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
2560                        if debug:
2561                            print('b-exchange', b_exchange)
2562                        assert b_exchange['rate'] == rate
2563                    case 3:  # transfer
2564                        _, x, a, b, desc, a_balance, b_balance = case
2565                        self.transfer(x, a, b, desc, debug=debug)
2566
2567                        cached_value = self.balance(a, cached=True)
2568                        fresh_value = self.balance(a, cached=False)
2569                        if debug:
2570                            print('account', a, 'cached_value', cached_value, 'fresh_value', fresh_value)
2571                        assert cached_value == a_balance
2572                        assert fresh_value == a_balance
2573
2574                        cached_value = self.balance(b, cached=True)
2575                        fresh_value = self.balance(b, cached=False)
2576                        if debug:
2577                            print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value)
2578                        assert cached_value == b_balance
2579                        assert fresh_value == b_balance
2580
2581            # Transfer all in many chunks randomly from B to A
2582            a_SAR_balance = 1371.25
2583            b_USD_balance = 501
2584            b_USD_exchange = self.exchange(b_USD)
2585            amounts = ZakatTracker.create_random_list(b_USD_balance)
2586            if debug:
2587                print('amounts', amounts)
2588            i = 0
2589            for x in amounts:
2590                if debug:
2591                    print(f'{i} - transfer-with-exchange({x})')
2592                self.transfer(x, b_USD, a_SAR, f"{x} USD -> SAR", debug=debug)
2593
2594                b_USD_balance -= x
2595                cached_value = self.balance(b_USD, cached=True)
2596                fresh_value = self.balance(b_USD, cached=False)
2597                if debug:
2598                    print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
2599                          b_USD_balance)
2600                assert cached_value == b_USD_balance
2601                assert fresh_value == b_USD_balance
2602
2603                a_SAR_balance += x * b_USD_exchange['rate']
2604                cached_value = self.balance(a_SAR, cached=True)
2605                fresh_value = self.balance(a_SAR, cached=False)
2606                if debug:
2607                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
2608                          a_SAR_balance, 'rate', b_USD_exchange['rate'])
2609                assert cached_value == a_SAR_balance
2610                assert fresh_value == a_SAR_balance
2611                i += 1
2612
2613            # Transfer all in many chunks randomly from C to A
2614            c_SAR_balance = 375
2615            amounts = ZakatTracker.create_random_list(c_SAR_balance)
2616            if debug:
2617                print('amounts', amounts)
2618            i = 0
2619            for x in amounts:
2620                if debug:
2621                    print(f'{i} - transfer-with-exchange({x})')
2622                self.transfer(x, c_SAR, a_SAR, f"{x} SAR -> a_SAR", debug=debug)
2623
2624                c_SAR_balance -= x
2625                cached_value = self.balance(c_SAR, cached=True)
2626                fresh_value = self.balance(c_SAR, cached=False)
2627                if debug:
2628                    print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
2629                          c_SAR_balance)
2630                assert cached_value == c_SAR_balance
2631                assert fresh_value == c_SAR_balance
2632
2633                a_SAR_balance += x
2634                cached_value = self.balance(a_SAR, cached=True)
2635                fresh_value = self.balance(a_SAR, cached=False)
2636                if debug:
2637                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
2638                          a_SAR_balance)
2639                assert cached_value == a_SAR_balance
2640                assert fresh_value == a_SAR_balance
2641                i += 1
2642
2643            assert self.export_json("accounts-transfer-with-exchange-rates.json")
2644            assert self.save("accounts-transfer-with-exchange-rates.pickle")
2645
2646            # check & zakat with exchange rates for many cycles
2647
2648            for rate, values in {
2649                1: {
2650                    'in': [1000, 2000, 10000],
2651                    'exchanged': [1000, 2000, 10000],
2652                    'out': [25, 50, 731.40625],
2653                },
2654                3.75: {
2655                    'in': [200, 1000, 5000],
2656                    'exchanged': [750, 3750, 18750],
2657                    'out': [18.75, 93.75, 1371.38671875],
2658                },
2659            }.items():
2660                a, b, c = values['in']
2661                m, n, o = values['exchanged']
2662                x, y, z = values['out']
2663                if debug:
2664                    print('rate', rate, 'values', values)
2665                for case in [
2666                    (a, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
2667                        {'safe': {0: {'below_nisab': x}}},
2668                    ], False, m),
2669                    (b, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
2670                        {'safe': {0: {'count': 1, 'total': y}}},
2671                    ], True, n),
2672                    (c, 'cave', ZakatTracker.time() - (ZakatTracker.TimeCycle() * 3), [
2673                        {'cave': {0: {'count': 3, 'total': z}}},
2674                    ], True, o),
2675                ]:
2676                    if debug:
2677                        print(f"############# check(rate: {rate}) #############")
2678                    self.reset()
2679                    self.exchange(account=case[1], created=case[2], rate=rate)
2680                    self.track(value=case[0], desc='test-check', account=case[1], logging=True, created=case[2])
2681
2682                    # assert self.nolock()
2683                    # history_size = len(self._vault['history'])
2684                    # print('history_size', history_size)
2685                    # assert history_size == 2
2686                    assert self.lock()
2687                    assert not self.nolock()
2688                    report = self.check(2.17, None, debug)
2689                    (valid, brief, plan) = report
2690                    assert valid == case[4]
2691                    if debug:
2692                        print('brief', brief)
2693                    assert case[5] == brief[0]
2694                    assert case[5] == brief[1]
2695
2696                    if debug:
2697                        pp().pprint(plan)
2698
2699                    for x in plan:
2700                        assert case[1] == x
2701                        if 'total' in case[3][0][x][0].keys():
2702                            assert case[3][0][x][0]['total'] == brief[2]
2703                            assert plan[x][0]['total'] == case[3][0][x][0]['total']
2704                            assert plan[x][0]['count'] == case[3][0][x][0]['count']
2705                        else:
2706                            assert plan[x][0]['below_nisab'] == case[3][0][x][0]['below_nisab']
2707                    if debug:
2708                        pp().pprint(report)
2709                    result = self.zakat(report, debug=debug)
2710                    if debug:
2711                        print('zakat-result', result, case[4])
2712                    assert result == case[4]
2713                    report = self.check(2.17, None, debug)
2714                    (valid, brief, plan) = report
2715                    assert valid is False
2716
2717            history_size = len(self._vault['history'])
2718            if debug:
2719                print('history_size', history_size)
2720            assert history_size == 3
2721            assert not self.nolock()
2722            assert self.recall(False, debug) is False
2723            self.free(self.lock())
2724            assert self.nolock()
2725
2726            for i in range(3, 0, -1):
2727                history_size = len(self._vault['history'])
2728                if debug:
2729                    print('history_size', history_size)
2730                assert history_size == i
2731                assert self.recall(False, debug) is True
2732
2733            assert self.nolock()
2734            assert self.recall(False, debug) is False
2735
2736            history_size = len(self._vault['history'])
2737            if debug:
2738                print('history_size', history_size)
2739            assert history_size == 0
2740
2741            account_size = len(self._vault['account'])
2742            if debug:
2743                print('account_size', account_size)
2744            assert account_size == 0
2745
2746            report_size = len(self._vault['report'])
2747            if debug:
2748                print('report_size', report_size)
2749            assert report_size == 0
2750
2751            assert self.nolock()
2752            return True
2753        except:
2754            # pp().pprint(self._vault)
2755            assert self.export_json("test-snapshot.json")
2756            assert self.save("test-snapshot.pickle")
2757            raise
def test(debug: bool = False):
2760def test(debug: bool = False):
2761    ledger = ZakatTracker()
2762    start = ZakatTracker.time()
2763    assert ledger.test(debug=debug)
2764    if debug:
2765        print("#########################")
2766        print("######## TEST DONE ########")
2767        print("#########################")
2768        print(ZakatTracker.duration_from_nanoseconds(ZakatTracker.time() - start))
2769        print("#########################")
class Action(enum.Enum):
73class Action(Enum):
74    CREATE = auto()
75    TRACK = auto()
76    LOG = auto()
77    SUB = auto()
78    ADD_FILE = auto()
79    REMOVE_FILE = auto()
80    BOX_TRANSFER = auto()
81    EXCHANGE = auto()
82    REPORT = auto()
83    ZAKAT = auto()
CREATE = <Action.CREATE: 1>
TRACK = <Action.TRACK: 2>
LOG = <Action.LOG: 3>
SUB = <Action.SUB: 4>
ADD_FILE = <Action.ADD_FILE: 5>
REMOVE_FILE = <Action.REMOVE_FILE: 6>
BOX_TRANSFER = <Action.BOX_TRANSFER: 7>
EXCHANGE = <Action.EXCHANGE: 8>
REPORT = <Action.REPORT: 9>
ZAKAT = <Action.ZAKAT: 10>
class JSONEncoder(json.encoder.JSONEncoder):
86class JSONEncoder(json.JSONEncoder):
87    def default(self, obj):
88        if isinstance(obj, Action) or isinstance(obj, MathOperation):
89            return obj.name  # Serialize as the enum member's name
90        elif isinstance(obj, Decimal):
91            return float(obj)
92        return super().default(obj)

Extensible JSON https://json.org encoder for Python data structures.

Supports the following objects and types by default:

+-------------------+---------------+ | Python | JSON | +===================+===============+ | dict | object | +-------------------+---------------+ | list, tuple | array | +-------------------+---------------+ | str | string | +-------------------+---------------+ | int, float | number | +-------------------+---------------+ | True | true | +-------------------+---------------+ | False | false | +-------------------+---------------+ | None | null | +-------------------+---------------+

To extend this to recognize other objects, subclass and implement a .default() method with another method that returns a serializable object for o if possible, otherwise it should call the superclass implementation (to raise TypeError).

def default(self, obj):
87    def default(self, obj):
88        if isinstance(obj, Action) or isinstance(obj, MathOperation):
89            return obj.name  # Serialize as the enum member's name
90        elif isinstance(obj, Decimal):
91            return float(obj)
92        return super().default(obj)

Implement this method in a subclass such that it returns a serializable object for o, or calls the base implementation (to raise a TypeError).

For example, to support arbitrary iterators, you could implement default like this::

def default(self, o):
    try:
        iterable = iter(o)
    except TypeError:
        pass
    else:
        return list(iterable)
    # Let the base class default method raise the TypeError
    return super().default(o)
class MathOperation(enum.Enum):
95class MathOperation(Enum):
96    ADDITION = auto()
97    EQUAL = auto()
98    SUBTRACTION = auto()
ADDITION = <MathOperation.ADDITION: 1>
EQUAL = <MathOperation.EQUAL: 2>
SUBTRACTION = <MathOperation.SUBTRACTION: 3>
def start_file_server( database_path: str, database_callback: <built-in function callable> = None, csv_callback: <built-in function callable> = None, debug: bool = False) -> tuple:
 55def start_file_server(database_path: str, database_callback: callable = None, csv_callback: callable = None,
 56                      debug: bool = False) -> tuple:
 57    """
 58    Starts a multi-purpose HTTP server to manage file interactions for a Zakat application.
 59
 60    This server facilitates the following functionalities:
 61
 62    1. GET /{file_uuid}/get: Download the database file specified by `database_path`.
 63    2. GET /{file_uuid}/upload: Display an HTML form for uploading files.
 64    3. POST /{file_uuid}/upload: Handle file uploads, distinguishing between:
 65        - Database File (.db): Replaces the existing database with the uploaded one.
 66        - CSV File (.csv): Imports data from the CSV into the existing database.
 67
 68    Args:
 69        database_path (str): The path to the pickle database file.
 70        database_callback (callable, optional): A function to call after a successful database upload.
 71                                                It receives the uploaded database path as its argument.
 72        csv_callback (callable, optional): A function to call after a successful CSV upload. It receives the uploaded CSV path,
 73                                           the database path, and the debug flag as its arguments.
 74        debug (bool, optional): If True, print debugging information. Defaults to False.
 75
 76    Returns:
 77        Tuple[str, str, str, threading.Thread, Callable[[], None]]: A tuple containing:
 78            - file_name (str): The name of the database file.
 79            - download_url (str): The URL to download the database file.
 80            - upload_url (str): The URL to access the file upload form.
 81            - server_thread (threading.Thread): The thread running the server.
 82            - shutdown_server (Callable[[], None]): A function to gracefully shut down the server.
 83
 84    Example:
 85        _, download_url, upload_url, server_thread, shutdown_server = start_file_server("zakat.db")
 86        print(f"Download database: {download_url}")
 87        print(f"Upload files: {upload_url}")
 88        server_thread.start()
 89        # ... later ...
 90        shutdown_server()
 91    """
 92    file_uuid = uuid.uuid4()
 93    file_name = os.path.basename(database_path)
 94
 95    port = find_available_port()
 96    download_url = f"http://localhost:{port}/{file_uuid}/get"
 97    upload_url = f"http://localhost:{port}/{file_uuid}/upload"
 98
 99    class Handler(http.server.SimpleHTTPRequestHandler):
100        def do_GET(self):
101            if self.path == f"/{file_uuid}/get":
102                # GET: Serve the existing file
103                try:
104                    with open(database_path, "rb") as f:
105                        self.send_response(200)
106                        self.send_header("Content-type", "application/octet-stream")
107                        self.send_header("Content-Disposition", f'attachment; filename="{file_name}"')
108                        self.end_headers()
109                        self.wfile.write(f.read())
110                except FileNotFoundError:
111                    self.send_error(404, "File not found")
112            elif self.path == f"/{file_uuid}/upload":
113                # GET: Serve the upload form
114                self.send_response(200)
115                self.send_header("Content-type", "text/html")
116                self.end_headers()
117                self.wfile.write(f"""
118                    <html lang="en">
119                        <head>
120                            <title>Zakat File Server</title>
121                        </head>
122                    <body>
123                    <h1>Zakat File Server</h1>
124                    <h3>You can download the <a target="__blank" href="{download_url}">database file</a>...</h3>
125                    <h3>Or upload a new file to restore a database or import `CSV` file:</h3>
126                    <form action="/{file_uuid}/upload" method="post" enctype="multipart/form-data">
127                        <input type="file" name="file" required><br/>
128                        <input type="radio" id="{FileType.Database.value}" name="upload_type" value="{FileType.Database.value}" required>
129                        <label for="database">Database File</label><br/>
130                        <input type="radio"id="{FileType.CSV.value}" name="upload_type" value="{FileType.CSV.value}">
131                        <label for="csv">CSV File</label><br/>
132                        <input type="submit" value="Upload"><br/>
133                    </form>
134                    </body></html>
135                """.encode())
136            else:
137                self.send_error(404)
138
139        def do_POST(self):
140            if self.path == f"/{file_uuid}/upload":
141                # POST: Handle request
142                # 1. Get the Form Data
143                form_data = cgi.FieldStorage(
144                    fp=self.rfile,
145                    headers=self.headers,
146                    environ={'REQUEST_METHOD': 'POST'}
147                )
148                upload_type = form_data.getvalue("upload_type")
149
150                if debug:
151                    print('upload_type', upload_type)
152
153                if upload_type not in [FileType.Database.value, FileType.CSV.value]:
154                    self.send_error(400, "Invalid upload type")
155                    return
156
157                # 2. Extract File Data
158                file_item = form_data['file']  # Assuming 'file' is your file input name
159
160                # 3. Get File Details
161                filename = file_item.filename
162                file_data = file_item.file.read()  # Read the file's content
163
164                if debug:
165                    print(f'Uploaded filename: {filename}')
166
167                # 4. Define Storage Path for CSV
168                upload_directory = "./uploads"  # Create this directory if it doesn't exist
169                os.makedirs(upload_directory, exist_ok=True)
170                file_path = os.path.join(upload_directory, upload_type)
171
172                # 5. Write to Disk
173                with open(file_path, 'wb') as f:
174                    f.write(file_data)
175
176                match upload_type:
177                    case FileType.Database.value:
178
179                        try:
180                            # 6. Verify database file
181                            # ZakatTracker(db_path=file_path) # FATAL, Circular Imports Error
182                            if database_callback is not None:
183                                database_callback(file_path)
184
185                            # 7. Copy database into the original path
186                            shutil.copy2(file_path, database_path)
187                        except Exception as e:
188                            self.send_error(400, str(e))
189                            return
190
191                    case FileType.CSV.value:
192                        # 6. Verify CSV file
193                        try:
194                            # x = ZakatTracker(db_path=database_path) # FATAL, Circular Imports Error
195                            # result = x.import_csv(file_path, debug=debug)
196                            if csv_callback is not None:
197                                result = csv_callback(file_path, database_path, debug)
198                                if debug:
199                                    print(f'CSV imported: {result}')
200                                if len(result[2]) != 0:
201                                    self.send_response(200)
202                                    self.end_headers()
203                                    self.wfile.write(json.dumps(result).encode())
204                                    return
205                        except Exception as e:
206                            self.send_error(400, str(e))
207                            return
208
209                self.send_response(200)
210                self.end_headers()
211                self.wfile.write(b"File uploaded successfully.")
212
213    httpd = socketserver.TCPServer(("localhost", port), Handler)
214    server_thread = threading.Thread(target=httpd.serve_forever)
215
216    def shutdown_server():
217        nonlocal httpd, server_thread
218        httpd.shutdown()
219        httpd.server_close()  # Close the socket
220        server_thread.join()  # Wait for the thread to finish
221
222    return file_name, download_url, upload_url, server_thread, shutdown_server

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

This server facilitates the following functionalities:

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

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

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

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

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

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

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

Returns: int: The available TCP port number.

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

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

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