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

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 clean_history(self, lock: int | None = None) -> int:
339    def clean_history(self, lock: int | None = None) -> int:
340        """
341        Cleans up the history of actions performed on the ZakatTracker instance.
342
343        Parameters:
344        lock (int, optional): The lock ID is used to clean up the empty history.
345            If not provided, it cleans up the empty history records for all locks.
346
347        Returns:
348        int: The number of locks cleaned up.
349        """
350        count = 0
351        if lock in self._vault['history']:
352            if len(self._vault['history'][lock]) <= 0:
353                count += 1
354                del self._vault['history'][lock]
355            return count
356        self.free(self.lock())
357        for lock in self._vault['history']:
358            if len(self._vault['history'][lock]) <= 0:
359                count += 1
360                del self._vault['history'][lock]
361        return count

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

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

Returns: int: The number of locks cleaned up.

def nolock(self) -> bool:
399    def nolock(self) -> bool:
400        """
401        Check if the vault lock is currently not set.
402
403        Returns:
404        bool: True if the vault lock is not set, False otherwise.
405        """
406        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:
408    def lock(self) -> int:
409        """
410        Acquires a lock on the ZakatTracker instance.
411
412        Returns:
413        int: The lock ID. This ID can be used to release the lock later.
414        """
415        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:
417    def vault(self) -> dict:
418        """
419        Returns a copy of the internal vault dictionary.
420
421        This method is used to retrieve the current state of the ZakatTracker object.
422        It provides a snapshot of the internal data structure, allowing for further
423        processing or analysis.
424
425        Returns:
426        dict: A copy of the internal vault dictionary.
427        """
428        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]:
430    def stats(self) -> dict[str, tuple]:
431        """
432        Calculates and returns statistics about the object's data storage.
433
434        This method determines the size of the database file on disk and the
435        size of the data currently held in RAM (likely within a dictionary).
436        Both sizes are reported in bytes and in a human-readable format
437        (e.g., KB, MB).
438
439        Returns:
440        dict[str, tuple]: A dictionary containing the following statistics:
441
442            * 'database': A tuple with two elements:
443                - The database file size in bytes (int).
444                - The database file size in human-readable format (str).
445            * 'ram': A tuple with two elements:
446                - The RAM usage (dictionary size) in bytes (int).
447                - The RAM usage in human-readable format (str).
448
449        Example:
450        >>> stats = my_object.stats()
451        >>> print(stats['database'])
452        (256000, '250.0 KB')
453        >>> print(stats['ram'])
454        (12345, '12.1 KB')
455        """
456        ram_size = self.get_dict_size(self.vault())
457        file_size = os.path.getsize(self.path())
458        return {
459            'database': (file_size, self.human_readable_size(file_size)),
460            'ram': (ram_size, self.human_readable_size(ram_size)),
461        }

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:
463    def steps(self) -> dict:
464        """
465        Returns a copy of the history of steps taken in the ZakatTracker.
466
467        The history is a dictionary where each key is a unique identifier for a step,
468        and the corresponding value is a dictionary containing information about the step.
469
470        Returns:
471        dict: A copy of the history of steps taken in the ZakatTracker.
472        """
473        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:
475    def free(self, lock: int, auto_save: bool = True) -> bool:
476        """
477        Releases the lock on the database.
478
479        Parameters:
480        lock (int): The lock ID to be released.
481        auto_save (bool): Whether to automatically save the database after releasing the lock.
482
483        Returns:
484        bool: True if the lock is successfully released and (optionally) saved, False otherwise.
485        """
486        if lock == self._vault['lock']:
487            self._vault['lock'] = None
488            self.clean_history(lock)
489            if auto_save:
490                return self.save(self.path())
491            return True
492        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:
494    def account_exists(self, account) -> bool:
495        """
496        Check if the given account exists in the vault.
497
498        Parameters:
499        account (str): The account number to check.
500
501        Returns:
502        bool: True if the account exists, False otherwise.
503        """
504        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:
506    def box_size(self, account) -> int:
507        """
508        Calculate the size of the box for a specific account.
509
510        Parameters:
511        account (str): The account number for which the box size needs to be calculated.
512
513        Returns:
514        int: The size of the box for the given account. If the account does not exist, -1 is returned.
515        """
516        if self.account_exists(account):
517            return len(self._vault['account'][account]['box'])
518        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:
520    def log_size(self, account) -> int:
521        """
522        Get the size of the log for a specific account.
523
524        Parameters:
525        account (str): The account number for which the log size needs to be calculated.
526
527        Returns:
528        int: The size of the log for the given account. If the account does not exist, -1 is returned.
529        """
530        if self.account_exists(account):
531            return len(self._vault['account'][account]['log'])
532        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:
534    def recall(self, dry=True, debug=False) -> bool:
535        """
536        Revert the last operation.
537
538        Parameters:
539        dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
540        debug (bool): If True, the function will print debug information. Default is False.
541
542        Returns:
543        bool: True if the operation was successful, False otherwise.
544        """
545        if not self.nolock() or len(self._vault['history']) == 0:
546            return False
547        if len(self._vault['history']) <= 0:
548            return False
549        ref = sorted(self._vault['history'].keys())[-1]
550        if debug:
551            print('recall', ref)
552        memory = self._vault['history'][ref]
553        if debug:
554            print(type(memory), 'memory', memory)
555
556        limit = len(memory) + 1
557        sub_positive_log_negative = 0
558        for i in range(-1, -limit, -1):
559            x = memory[i]
560            if debug:
561                print(type(x), x)
562            match x['action']:
563                case Action.CREATE:
564                    if x['account'] is not None:
565                        if self.account_exists(x['account']):
566                            if debug:
567                                print('account', self._vault['account'][x['account']])
568                            assert len(self._vault['account'][x['account']]['box']) == 0
569                            assert self._vault['account'][x['account']]['balance'] == 0
570                            assert self._vault['account'][x['account']]['count'] == 0
571                            if dry:
572                                continue
573                            del self._vault['account'][x['account']]
574
575                case Action.TRACK:
576                    if x['account'] is not None:
577                        if self.account_exists(x['account']):
578                            if dry:
579                                continue
580                            self._vault['account'][x['account']]['balance'] -= x['value']
581                            self._vault['account'][x['account']]['count'] -= 1
582                            del self._vault['account'][x['account']]['box'][x['ref']]
583
584                case Action.LOG:
585                    if x['account'] is not None:
586                        if self.account_exists(x['account']):
587                            if x['ref'] in self._vault['account'][x['account']]['log']:
588                                if dry:
589                                    continue
590                                if sub_positive_log_negative == -x['value']:
591                                    self._vault['account'][x['account']]['count'] -= 1
592                                    sub_positive_log_negative = 0
593                                box_ref = self._vault['account'][x['account']]['log'][x['ref']]['ref']
594                                if not box_ref is None:
595                                    assert self.box_exists(x['account'], box_ref)
596                                    box_value = self._vault['account'][x['account']]['log'][x['ref']]['value']
597                                    assert box_value < 0
598                                    self._vault['account'][x['account']]['box'][box_ref]['rest'] += -box_value
599                                    self._vault['account'][x['account']]['balance'] += -box_value
600                                    self._vault['account'][x['account']]['count'] -= 1
601                                del self._vault['account'][x['account']]['log'][x['ref']]
602
603                case Action.SUB:
604                    if x['account'] is not None:
605                        if self.account_exists(x['account']):
606                            if x['ref'] in self._vault['account'][x['account']]['box']:
607                                if dry:
608                                    continue
609                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] += x['value']
610                                self._vault['account'][x['account']]['balance'] += x['value']
611                                sub_positive_log_negative = x['value']
612
613                case Action.ADD_FILE:
614                    if x['account'] is not None:
615                        if self.account_exists(x['account']):
616                            if x['ref'] in self._vault['account'][x['account']]['log']:
617                                if x['file'] in self._vault['account'][x['account']]['log'][x['ref']]['file']:
618                                    if dry:
619                                        continue
620                                    del self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']]
621
622                case Action.REMOVE_FILE:
623                    if x['account'] is not None:
624                        if self.account_exists(x['account']):
625                            if x['ref'] in self._vault['account'][x['account']]['log']:
626                                if dry:
627                                    continue
628                                self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']] = x['value']
629
630                case Action.BOX_TRANSFER:
631                    if x['account'] is not None:
632                        if self.account_exists(x['account']):
633                            if x['ref'] in self._vault['account'][x['account']]['box']:
634                                if dry:
635                                    continue
636                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] -= x['value']
637
638                case Action.EXCHANGE:
639                    if x['account'] is not None:
640                        if x['account'] in self._vault['exchange']:
641                            if x['ref'] in self._vault['exchange'][x['account']]:
642                                if dry:
643                                    continue
644                                del self._vault['exchange'][x['account']][x['ref']]
645
646                case Action.REPORT:
647                    if x['ref'] in self._vault['report']:
648                        if dry:
649                            continue
650                        del self._vault['report'][x['ref']]
651
652                case Action.ZAKAT:
653                    if x['account'] is not None:
654                        if self.account_exists(x['account']):
655                            if x['ref'] in self._vault['account'][x['account']]['box']:
656                                if x['key'] in self._vault['account'][x['account']]['box'][x['ref']]:
657                                    if dry:
658                                        continue
659                                    match x['math']:
660                                        case MathOperation.ADDITION:
661                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] -= x[
662                                                'value']
663                                        case MathOperation.EQUAL:
664                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] = x['value']
665                                        case MathOperation.SUBTRACTION:
666                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] += x[
667                                                'value']
668
669        if not dry:
670            del self._vault['history'][ref]
671        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:
673    def ref_exists(self, account: str, ref_type: str, ref: int) -> bool:
674        """
675        Check if a specific reference (transaction) exists in the vault for a given account and reference type.
676
677        Parameters:
678        account (str): The account number for which to check the existence of the reference.
679        ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
680        ref (int): The reference (transaction) number to check for existence.
681
682        Returns:
683        bool: True if the reference exists for the given account and reference type, False otherwise.
684        """
685        if account in self._vault['account']:
686            return ref in self._vault['account'][account][ref_type]
687        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:
689    def box_exists(self, account: str, ref: int) -> bool:
690        """
691        Check if a specific box (transaction) exists in the vault for a given account and reference.
692
693        Parameters:
694        - account (str): The account number for which to check the existence of the box.
695        - ref (int): The reference (transaction) number to check for existence.
696
697        Returns:
698        - bool: True if the box exists for the given account and reference, False otherwise.
699        """
700        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:
702    def track(self, value: float = 0, desc: str = '', account: str = 1, logging: bool = True, created: int = None,
703              debug: bool = False) -> int:
704        """
705        This function tracks a transaction for a specific account.
706
707        Parameters:
708        value (float): The value of the transaction. Default is 0.
709        desc (str): The description of the transaction. Default is an empty string.
710        account (str): The account for which the transaction is being tracked. Default is '1'.
711        logging (bool): Whether to log the transaction. Default is True.
712        created (int): The timestamp of the transaction. If not provided, it will be generated. Default is None.
713        debug (bool): Whether to print debug information. Default is False.
714
715        Returns:
716        int: The timestamp of the transaction.
717
718        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.
719
720        Raises:
721        ValueError: The log transaction happened again in the same nanosecond time.
722        ValueError: The box transaction happened again in the same nanosecond time.
723        """
724        if debug:
725            print('track', f'debug={debug}')
726        if created is None:
727            created = self.time()
728        no_lock = self.nolock()
729        self.lock()
730        if not self.account_exists(account):
731            if debug:
732                print(f"account {account} created")
733            self._vault['account'][account] = {
734                'balance': 0,
735                'box': {},
736                'count': 0,
737                'log': {},
738                'hide': False,
739                'zakatable': True,
740            }
741            self._step(Action.CREATE, account)
742        if value == 0:
743            if no_lock:
744                self.free(self.lock())
745            return 0
746        if logging:
747            self._log(value=value, desc=desc, account=account, created=created, ref=None, debug=debug)
748        if debug:
749            print('create-box', created)
750        if self.box_exists(account, created):
751            raise ValueError(f"The box transaction happened again in the same nanosecond time({created}).")
752        if debug:
753            print('created-box', created)
754        self._vault['account'][account]['box'][created] = {
755            'capital': value,
756            'count': 0,
757            'last': 0,
758            'rest': value,
759            'total': 0,
760        }
761        self._step(Action.TRACK, account, ref=created, value=value)
762        if no_lock:
763            self.free(self.lock())
764        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:
766    def log_exists(self, account: str, ref: int) -> bool:
767        """
768        Checks if a specific transaction log entry exists for a given account.
769
770        Parameters:
771        account (str): The account number associated with the transaction log.
772        ref (int): The reference to the transaction log entry.
773
774        Returns:
775        bool: True if the transaction log entry exists, False otherwise.
776        """
777        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:
823    def exchange(self, account, created: int = None, rate: float = None, description: str = None,
824                 debug: bool = False) -> dict:
825        """
826        This method is used to record or retrieve exchange rates for a specific account.
827
828        Parameters:
829        - account (str): The account number for which the exchange rate is being recorded or retrieved.
830        - created (int): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
831        - rate (float): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
832        - description (str): A description of the exchange rate.
833
834        Returns:
835        - dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found,
836        it returns a dictionary with default values for the rate and description.
837        """
838        if debug:
839            print('exchange', f'debug={debug}')
840        if created is None:
841            created = self.time()
842        no_lock = self.nolock()
843        self.lock()
844        if rate is not None:
845            if rate <= 0:
846                return dict()
847            if account not in self._vault['exchange']:
848                self._vault['exchange'][account] = {}
849            if len(self._vault['exchange'][account]) == 0 and rate <= 1:
850                return {"time": created, "rate": 1, "description": None}
851            self._vault['exchange'][account][created] = {"rate": rate, "description": description}
852            self._step(Action.EXCHANGE, account, ref=created, value=rate)
853            if no_lock:
854                self.free(self.lock())
855            if debug:
856                print("exchange-created-1",
857                      f'account: {account}, created: {created}, rate:{rate}, description:{description}')
858
859        if account in self._vault['exchange']:
860            valid_rates = [(ts, r) for ts, r in self._vault['exchange'][account].items() if ts <= created]
861            if valid_rates:
862                latest_rate = max(valid_rates, key=lambda x: x[0])
863                if debug:
864                    print("exchange-read-1",
865                          f'account: {account}, created: {created}, rate:{rate}, description:{description}',
866                          'latest_rate', latest_rate)
867                result = latest_rate[1]
868                result['time'] = latest_rate[0]
869                return result  # إرجاع قاموس يحتوي على المعدل والوصف
870        if debug:
871            print("exchange-read-0", f'account: {account}, created: {created}, rate:{rate}, description:{description}')
872        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:
874    @staticmethod
875    def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
876        """
877        This function calculates the exchanged amount of a currency.
878
879        Args:
880            x (float): The original amount of the currency.
881            x_rate (float): The exchange rate of the original currency.
882            y_rate (float): The exchange rate of the target currency.
883
884        Returns:
885            float: The exchanged amount of the target currency.
886        """
887        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:
889    def exchanges(self) -> dict:
890        """
891        Retrieve the recorded exchange rates for all accounts.
892
893        Parameters:
894        None
895
896        Returns:
897        dict: A dictionary containing all recorded exchange rates.
898        The keys are account names or numbers, and the values are dictionaries containing the exchange rates.
899        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
900        """
901        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:
903    def accounts(self) -> dict:
904        """
905        Returns a dictionary containing account numbers as keys and their respective balances as values.
906
907        Parameters:
908        None
909
910        Returns:
911        dict: A dictionary where keys are account numbers and values are their respective balances.
912        """
913        result = {}
914        for i in self._vault['account']:
915            result[i] = self._vault['account'][i]['balance']
916        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:
918    def boxes(self, account) -> dict:
919        """
920        Retrieve the boxes (transactions) associated with a specific account.
921
922        Parameters:
923        account (str): The account number for which to retrieve the boxes.
924
925        Returns:
926        dict: A dictionary containing the boxes associated with the given account.
927        If the account does not exist, an empty dictionary is returned.
928        """
929        if self.account_exists(account):
930            return self._vault['account'][account]['box']
931        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:
933    def logs(self, account) -> dict:
934        """
935        Retrieve the logs (transactions) associated with a specific account.
936
937        Parameters:
938        account (str): The account number for which to retrieve the logs.
939
940        Returns:
941        dict: A dictionary containing the logs associated with the given account.
942        If the account does not exist, an empty dictionary is returned.
943        """
944        if self.account_exists(account):
945            return self._vault['account'][account]['log']
946        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:
948    def add_file(self, account: str, ref: int, path: str) -> int:
949        """
950        Adds a file reference to a specific transaction log entry in the vault.
951
952        Parameters:
953        account (str): The account number associated with the transaction log.
954        ref (int): The reference to the transaction log entry.
955        path (str): The path of the file to be added.
956
957        Returns:
958        int: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
959        """
960        if self.account_exists(account):
961            if ref in self._vault['account'][account]['log']:
962                file_ref = self.time()
963                self._vault['account'][account]['log'][ref]['file'][file_ref] = path
964                no_lock = self.nolock()
965                self.lock()
966                self._step(Action.ADD_FILE, account, ref=ref, file=file_ref)
967                if no_lock:
968                    self.free(self.lock())
969                return file_ref
970        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:
972    def remove_file(self, account: str, ref: int, file_ref: int) -> bool:
973        """
974        Removes a file reference from a specific transaction log entry in the vault.
975
976        Parameters:
977        account (str): The account number associated with the transaction log.
978        ref (int): The reference to the transaction log entry.
979        file_ref (int): The reference of the file to be removed.
980
981        Returns:
982        bool: True if the file reference is successfully removed, False otherwise.
983        """
984        if self.account_exists(account):
985            if ref in self._vault['account'][account]['log']:
986                if file_ref in self._vault['account'][account]['log'][ref]['file']:
987                    x = self._vault['account'][account]['log'][ref]['file'][file_ref]
988                    del self._vault['account'][account]['log'][ref]['file'][file_ref]
989                    no_lock = self.nolock()
990                    self.lock()
991                    self._step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x)
992                    if no_lock:
993                        self.free(self.lock())
994                    return True
995        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:
 997    def balance(self, account: str = 1, cached: bool = True) -> int:
 998        """
 999        Calculate and return the balance of a specific account.
1000
1001        Parameters:
1002        account (str): The account number. Default is '1'.
1003        cached (bool): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
1004
1005        Returns:
1006        int: The balance of the account.
1007
1008        Note:
1009        If cached is True, the function returns the cached balance.
1010        If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
1011        """
1012        if cached:
1013            return self._vault['account'][account]['balance']
1014        x = 0
1015        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:
1017    def hide(self, account, status: bool = None) -> bool:
1018        """
1019        Check or set the hide status of a specific account.
1020
1021        Parameters:
1022        account (str): The account number.
1023        status (bool, optional): The new hide status. If not provided, the function will return the current status.
1024
1025        Returns:
1026        bool: The current or updated hide status of the account.
1027
1028        Raises:
1029        None
1030
1031        Example:
1032        >>> tracker = ZakatTracker()
1033        >>> ref = tracker.track(51, 'desc', 'account1')
1034        >>> tracker.hide('account1')  # Set the hide status of 'account1' to True
1035        False
1036        >>> tracker.hide('account1', True)  # Set the hide status of 'account1' to True
1037        True
1038        >>> tracker.hide('account1')  # Get the hide status of 'account1' by default
1039        True
1040        >>> tracker.hide('account1', False)
1041        False
1042        """
1043        if self.account_exists(account):
1044            if status is None:
1045                return self._vault['account'][account]['hide']
1046            self._vault['account'][account]['hide'] = status
1047            return status
1048        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:
1050    def zakatable(self, account, status: bool = None) -> bool:
1051        """
1052        Check or set the zakatable status of a specific account.
1053
1054        Parameters:
1055        account (str): The account number.
1056        status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
1057
1058        Returns:
1059        bool: The current or updated zakatable status of the account.
1060
1061        Raises:
1062        None
1063
1064        Example:
1065        >>> tracker = ZakatTracker()
1066        >>> ref = tracker.track(51, 'desc', 'account1')
1067        >>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
1068        True
1069        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
1070        True
1071        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
1072        True
1073        >>> tracker.zakatable('account1', False)
1074        False
1075        """
1076        if self.account_exists(account):
1077            if status is None:
1078                return self._vault['account'][account]['zakatable']
1079            self._vault['account'][account]['zakatable'] = status
1080            return status
1081        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:
1083    def sub(self, x: float, desc: str = '', account: str = 1, created: int = None, debug: bool = False) -> tuple:
1084        """
1085        Subtracts a specified value from an account's balance.
1086
1087        Parameters:
1088        x (float): The amount to be subtracted.
1089        desc (str): A description for the transaction. Defaults to an empty string.
1090        account (str): The account from which the value will be subtracted. Defaults to '1'.
1091        created (int): The timestamp of the transaction. If not provided, the current timestamp will be used.
1092        debug (bool): A flag indicating whether to print debug information. Defaults to False.
1093
1094        Returns:
1095        tuple: A tuple containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
1096
1097        If the amount to subtract is greater than the account's balance,
1098        the remaining amount will be transferred to a new transaction with a negative value.
1099
1100        Raises:
1101        ValueError: The box transaction happened again in the same nanosecond time.
1102        ValueError: The log transaction happened again in the same nanosecond time.
1103        """
1104        if debug:
1105            print('sub', f'debug={debug}')
1106        if x < 0:
1107            return tuple()
1108        if x == 0:
1109            ref = self.track(x, '', account)
1110            return ref, ref
1111        if created is None:
1112            created = self.time()
1113        no_lock = self.nolock()
1114        self.lock()
1115        self.track(0, '', account)
1116        self._log(value=-x, desc=desc, account=account, created=created, ref=None, debug=debug)
1117        ids = sorted(self._vault['account'][account]['box'].keys())
1118        limit = len(ids) + 1
1119        target = x
1120        if debug:
1121            print('ids', ids)
1122        ages = []
1123        for i in range(-1, -limit, -1):
1124            if target == 0:
1125                break
1126            j = ids[i]
1127            if debug:
1128                print('i', i, 'j', j)
1129            rest = self._vault['account'][account]['box'][j]['rest']
1130            if rest >= target:
1131                self._vault['account'][account]['box'][j]['rest'] -= target
1132                self._step(Action.SUB, account, ref=j, value=target)
1133                ages.append((j, target))
1134                target = 0
1135                break
1136            elif target > rest > 0:
1137                chunk = rest
1138                target -= chunk
1139                self._step(Action.SUB, account, ref=j, value=chunk)
1140                ages.append((j, chunk))
1141                self._vault['account'][account]['box'][j]['rest'] = 0
1142        if target > 0:
1143            self.track(-target, desc, account, False, created)
1144            ages.append((created, target))
1145        if no_lock:
1146            self.free(self.lock())
1147        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]:
1149    def transfer(self, amount: int, from_account: str, to_account: str, desc: str = '', created: int = None,
1150                 debug: bool = False) -> list[int]:
1151        """
1152        Transfers a specified value from one account to another.
1153
1154        Parameters:
1155        amount (int): The amount to be transferred.
1156        from_account (str): The account from which the value will be transferred.
1157        to_account (str): The account to which the value will be transferred.
1158        desc (str, optional): A description for the transaction. Defaults to an empty string.
1159        created (int, optional): The timestamp of the transaction. If not provided, the current timestamp will be used.
1160        debug (bool): A flag indicating whether to print debug information. Defaults to False.
1161
1162        Returns:
1163        list[int]: A list of timestamps corresponding to the transactions made during the transfer.
1164
1165        Raises:
1166        ValueError: Transfer to the same account is forbidden.
1167        ValueError: The box transaction happened again in the same nanosecond time.
1168        ValueError: The log transaction happened again in the same nanosecond time.
1169        """
1170        if debug:
1171            print('transfer', f'debug={debug}')
1172        if from_account == to_account:
1173            raise ValueError(f'Transfer to the same account is forbidden. {to_account}')
1174        if amount <= 0:
1175            return []
1176        if created is None:
1177            created = self.time()
1178        (_, ages) = self.sub(amount, desc, from_account, created, debug=debug)
1179        times = []
1180        source_exchange = self.exchange(from_account, created)
1181        target_exchange = self.exchange(to_account, created)
1182
1183        if debug:
1184            print('ages', ages)
1185
1186        for age, value in ages:
1187            target_amount = self.exchange_calc(value, source_exchange['rate'], target_exchange['rate'])
1188            # Perform the transfer
1189            if self.box_exists(to_account, age):
1190                if debug:
1191                    print('box_exists', age)
1192                capital = self._vault['account'][to_account]['box'][age]['capital']
1193                rest = self._vault['account'][to_account]['box'][age]['rest']
1194                if debug:
1195                    print(
1196                        f"Transfer {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1197                selected_age = age
1198                if rest + target_amount > capital:
1199                    self._vault['account'][to_account]['box'][age]['capital'] += target_amount
1200                    selected_age = ZakatTracker.time()
1201                self._vault['account'][to_account]['box'][age]['rest'] += target_amount
1202                self._step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount)
1203                y = self._log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account,
1204                              created=None, ref=None, debug=debug)
1205                times.append((age, y))
1206                continue
1207            y = self.track(target_amount, desc, to_account, logging=True, created=age, debug=debug)
1208            if debug:
1209                print(
1210                    f"Transferred {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1211            times.append(y)
1212        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:
1214    def check(self, silver_gram_price: float, nisab: float = None, debug: bool = False, now: int = None,
1215              cycle: float = None) -> tuple:
1216        """
1217        Check the eligibility for Zakat based on the given parameters.
1218
1219        Parameters:
1220        silver_gram_price (float): The price of a gram of silver.
1221        nisab (float): The minimum amount of wealth required for Zakat. If not provided,
1222                        it will be calculated based on the silver_gram_price.
1223        debug (bool): Flag to enable debug mode.
1224        now (int): The current timestamp. If not provided, it will be calculated using ZakatTracker.time().
1225        cycle (float): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
1226
1227        Returns:
1228        tuple: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics,
1229        and a dictionary containing the Zakat plan.
1230        """
1231        if debug:
1232            print('check', f'debug={debug}')
1233        if now is None:
1234            now = self.time()
1235        if cycle is None:
1236            cycle = ZakatTracker.TimeCycle()
1237        if nisab is None:
1238            nisab = ZakatTracker.Nisab(silver_gram_price)
1239        plan = {}
1240        below_nisab = 0
1241        brief = [0, 0, 0]
1242        valid = False
1243        if debug:
1244            print('exchanges', self.exchanges())
1245        for x in self._vault['account']:
1246            if not self.zakatable(x):
1247                continue
1248            _box = self._vault['account'][x]['box']
1249            _log = self._vault['account'][x]['log']
1250            limit = len(_box) + 1
1251            ids = sorted(self._vault['account'][x]['box'].keys())
1252            for i in range(-1, -limit, -1):
1253                j = ids[i]
1254                rest = float(_box[j]['rest'])
1255                if rest <= 0:
1256                    continue
1257                exchange = self.exchange(x, created=self.time())
1258                rest = ZakatTracker.exchange_calc(rest, float(exchange['rate']), 1)
1259                brief[0] += rest
1260                index = limit + i - 1
1261                epoch = (now - j) / cycle
1262                if debug:
1263                    print(f"Epoch: {epoch}", _box[j])
1264                if _box[j]['last'] > 0:
1265                    epoch = (now - _box[j]['last']) / cycle
1266                if debug:
1267                    print(f"Epoch: {epoch}")
1268                epoch = floor(epoch)
1269                if debug:
1270                    print(f"Epoch: {epoch}", type(epoch), epoch == 0, 1 - epoch, epoch)
1271                if epoch == 0:
1272                    continue
1273                if debug:
1274                    print("Epoch - PASSED")
1275                brief[1] += rest
1276                if rest >= nisab:
1277                    total = 0
1278                    for _ in range(epoch):
1279                        total += ZakatTracker.ZakatCut(float(rest) - float(total))
1280                    if total > 0:
1281                        if x not in plan:
1282                            plan[x] = {}
1283                        valid = True
1284                        brief[2] += total
1285                        plan[x][index] = {
1286                            'total': total,
1287                            'count': epoch,
1288                            'box_time': j,
1289                            'box_capital': _box[j]['capital'],
1290                            'box_rest': _box[j]['rest'],
1291                            'box_last': _box[j]['last'],
1292                            'box_total': _box[j]['total'],
1293                            'box_count': _box[j]['count'],
1294                            'box_log': _log[j]['desc'],
1295                            'exchange_rate': exchange['rate'],
1296                            'exchange_time': exchange['time'],
1297                            'exchange_desc': exchange['description'],
1298                        }
1299                else:
1300                    chunk = ZakatTracker.ZakatCut(float(rest))
1301                    if chunk > 0:
1302                        if x not in plan:
1303                            plan[x] = {}
1304                        if j not in plan[x].keys():
1305                            plan[x][index] = {}
1306                        below_nisab += rest
1307                        brief[2] += chunk
1308                        plan[x][index]['below_nisab'] = chunk
1309                        plan[x][index]['total'] = chunk
1310                        plan[x][index]['count'] = epoch
1311                        plan[x][index]['box_time'] = j
1312                        plan[x][index]['box_capital'] = _box[j]['capital']
1313                        plan[x][index]['box_rest'] = _box[j]['rest']
1314                        plan[x][index]['box_last'] = _box[j]['last']
1315                        plan[x][index]['box_total'] = _box[j]['total']
1316                        plan[x][index]['box_count'] = _box[j]['count']
1317                        plan[x][index]['box_log'] = _log[j]['desc']
1318                        plan[x][index]['exchange_rate'] = exchange['rate']
1319                        plan[x][index]['exchange_time'] = exchange['time']
1320                        plan[x][index]['exchange_desc'] = exchange['description']
1321        valid = valid or below_nisab >= nisab
1322        if debug:
1323            print(f"below_nisab({below_nisab}) >= nisab({nisab})")
1324        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:
1326    def build_payment_parts(self, demand: float, positive_only: bool = True) -> dict:
1327        """
1328        Build payment parts for the Zakat distribution.
1329
1330        Parameters:
1331        demand (float): The total demand for payment in local currency.
1332        positive_only (bool): If True, only consider accounts with positive balance. Default is True.
1333
1334        Returns:
1335        dict: A dictionary containing the payment parts for each account. The dictionary has the following structure:
1336        {
1337            'account': {
1338                'account_id': {'balance': float, 'rate': float, 'part': float},
1339                ...
1340            },
1341            'exceed': bool,
1342            'demand': float,
1343            'total': float,
1344        }
1345        """
1346        total = 0
1347        parts = {
1348            'account': {},
1349            'exceed': False,
1350            'demand': demand,
1351        }
1352        for x, y in self.accounts().items():
1353            if positive_only and y <= 0:
1354                continue
1355            total += float(y)
1356            exchange = self.exchange(x)
1357            parts['account'][x] = {'balance': y, 'rate': exchange['rate'], 'part': 0}
1358        parts['total'] = total
1359        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:
1361    @staticmethod
1362    def check_payment_parts(parts: dict, debug: bool = False) -> int:
1363        """
1364        Checks the validity of payment parts.
1365
1366        Parameters:
1367        parts (dict): A dictionary containing payment parts information.
1368        debug (bool): Flag to enable debug mode.
1369
1370        Returns:
1371        int: Returns 0 if the payment parts are valid, otherwise returns the error code.
1372
1373        Error Codes:
1374        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
1375        2: 'balance', 'rate' or 'part' key is missing in parts['account'][x].
1376        3: 'part' value in parts['account'][x] is less than 0.
1377        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
1378        5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
1379        6: The sum of 'part' values in parts['account'] does not match with 'demand' value.
1380        """
1381        if debug:
1382            print('check_payment_parts', f'debug={debug}')
1383        for i in ['demand', 'account', 'total', 'exceed']:
1384            if i not in parts:
1385                return 1
1386        exceed = parts['exceed']
1387        for x in parts['account']:
1388            for j in ['balance', 'rate', 'part']:
1389                if j not in parts['account'][x]:
1390                    return 2
1391                if parts['account'][x]['part'] < 0:
1392                    return 3
1393                if not exceed and parts['account'][x]['balance'] <= 0:
1394                    return 4
1395        demand = parts['demand']
1396        z = 0
1397        for _, y in parts['account'].items():
1398            if not exceed and y['part'] > y['balance']:
1399                return 5
1400            z += ZakatTracker.exchange_calc(y['part'], y['rate'], 1)
1401        z = round(z, 2)
1402        demand = round(demand, 2)
1403        if debug:
1404            print('check_payment_parts', f'z = {z}, demand = {demand}')
1405            print('check_payment_parts', type(z), type(demand))
1406            print('check_payment_parts', z != demand)
1407            print('check_payment_parts', str(z) != str(demand))
1408        if z != demand and str(z) != str(demand):
1409            return 6
1410        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:
1412    def zakat(self, report: tuple, parts: Dict[str, Dict | bool | Any] = None, debug: bool = False) -> bool:
1413        """
1414        Perform Zakat calculation based on the given report and optional parts.
1415
1416        Parameters:
1417        report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan.
1418        parts (dict): A dictionary containing the payment parts for the zakat.
1419        debug (bool): A flag indicating whether to print debug information.
1420
1421        Returns:
1422        bool: True if the zakat calculation is successful, False otherwise.
1423        """
1424        if debug:
1425            print('zakat', f'debug={debug}')
1426        valid, _, plan = report
1427        if not valid:
1428            return valid
1429        parts_exist = parts is not None
1430        if parts_exist:
1431            if self.check_payment_parts(parts, debug=debug) != 0:
1432                return False
1433        if debug:
1434            print('######### zakat #######')
1435            print('parts_exist', parts_exist)
1436        no_lock = self.nolock()
1437        self.lock()
1438        report_time = self.time()
1439        self._vault['report'][report_time] = report
1440        self._step(Action.REPORT, ref=report_time)
1441        created = self.time()
1442        for x in plan:
1443            target_exchange = self.exchange(x)
1444            if debug:
1445                print(plan[x])
1446                print('-------------')
1447                print(self._vault['account'][x]['box'])
1448            ids = sorted(self._vault['account'][x]['box'].keys())
1449            if debug:
1450                print('plan[x]', plan[x])
1451            for i in plan[x].keys():
1452                j = ids[i]
1453                if debug:
1454                    print('i', i, 'j', j)
1455                self._step(Action.ZAKAT, account=x, ref=j, value=self._vault['account'][x]['box'][j]['last'],
1456                           key='last',
1457                           math_operation=MathOperation.EQUAL)
1458                self._vault['account'][x]['box'][j]['last'] = created
1459                amount = ZakatTracker.exchange_calc(float(plan[x][i]['total']), 1, float(target_exchange['rate']))
1460                self._vault['account'][x]['box'][j]['total'] += amount
1461                self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='total',
1462                           math_operation=MathOperation.ADDITION)
1463                self._vault['account'][x]['box'][j]['count'] += plan[x][i]['count']
1464                self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['count'], key='count',
1465                           math_operation=MathOperation.ADDITION)
1466                if not parts_exist:
1467                    try:
1468                        self._vault['account'][x]['box'][j]['rest'] -= amount
1469                    except TypeError:
1470                        self._vault['account'][x]['box'][j]['rest'] -= Decimal(amount)
1471                    # self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest',
1472                    #            math_operation=MathOperation.SUBTRACTION)
1473                    self._log(-float(amount), desc='zakat-زكاة', account=x, created=None, ref=j, debug=debug)
1474        if parts_exist:
1475            for account, part in parts['account'].items():
1476                if part['part'] == 0:
1477                    continue
1478                if debug:
1479                    print('zakat-part', account, part['rate'])
1480                target_exchange = self.exchange(account)
1481                amount = ZakatTracker.exchange_calc(part['part'], part['rate'], target_exchange['rate'])
1482                self.sub(amount, desc='zakat-part-دفعة-زكاة', account=account, debug=debug)
1483        if no_lock:
1484            self.free(self.lock())
1485        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:
1487    def export_json(self, path: str = "data.json") -> bool:
1488        """
1489        Exports the current state of the ZakatTracker object to a JSON file.
1490
1491        Parameters:
1492        path (str): The path where the JSON file will be saved. Default is "data.json".
1493
1494        Returns:
1495        bool: True if the export is successful, False otherwise.
1496
1497        Raises:
1498        No specific exceptions are raised by this method.
1499        """
1500        with open(path, "w") as file:
1501            json.dump(self._vault, file, indent=4, cls=JSONEncoder)
1502            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:
1504    def save(self, path: str = None) -> bool:
1505        """
1506        Saves the ZakatTracker's current state to a pickle file.
1507
1508        This method serializes the internal data (`_vault`) along with metadata
1509        (Python version, pickle protocol) for future compatibility.
1510
1511        Parameters:
1512        path (str, optional): File path for saving. Defaults to a predefined location.
1513
1514        Returns:
1515        bool: True if the save operation is successful, False otherwise.
1516        """
1517        if path is None:
1518            path = self.path()
1519        with open(path, "wb") as f:
1520            version = f'{version_info.major}.{version_info.minor}.{version_info.micro}'
1521            pickle_protocol = pickle.HIGHEST_PROTOCOL
1522            data = {
1523                'python_version': version,
1524                'pickle_protocol': pickle_protocol,
1525                'data': self._vault,
1526            }
1527            pickle.dump(data, f, protocol=pickle_protocol)
1528            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:
1530    def load(self, path: str = None) -> bool:
1531        """
1532        Load the current state of the ZakatTracker object from a pickle file.
1533
1534        Parameters:
1535        path (str): The path where the pickle file is located. If not provided, it will use the default path.
1536
1537        Returns:
1538        bool: True if the load operation is successful, False otherwise.
1539        """
1540        if path is None:
1541            path = self.path()
1542        if os.path.exists(path):
1543            with open(path, "rb") as f:
1544                data = pickle.load(f)
1545                self._vault = data['data']
1546                return True
1547        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):
1549    def import_csv_cache_path(self):
1550        """
1551        Generates the cache file path for imported CSV data.
1552
1553        This function constructs the file path where cached data from CSV imports
1554        will be stored. The cache file is a pickle file (.pickle extension) appended
1555        to the base path of the object.
1556
1557        Returns:
1558        str: The full path to the import CSV cache file.
1559
1560        Example:
1561            >>> obj = ZakatTracker('/data/reports')
1562            >>> obj.import_csv_cache_path()
1563            '/data/reports.import_csv.pickle'
1564        """
1565        path = self.path()
1566        if path.endswith(".pickle"):
1567            path = path[:-7]
1568        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:
1570    def import_csv(self, path: str = 'file.csv', debug: bool = False) -> tuple:
1571        """
1572        The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system.
1573
1574        Parameters:
1575        path (str): The path to the CSV file. Default is 'file.csv'.
1576        debug (bool): A flag indicating whether to print debug information.
1577
1578        Returns:
1579        tuple: A tuple containing the number of transactions created, the number of transactions found in the cache,
1580                and a dictionary of bad transactions.
1581
1582        Notes:
1583            * Currency Pair Assumption: This function assumes that the exchange rates stored for each account
1584                                        are appropriate for the currency pairs involved in the conversions.
1585            * The exchange rate for each account is based on the last encountered transaction rate that is not equal
1586                to 1.0 or the previous rate for that account.
1587            * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent
1588              transactions of the same account within the whole imported and existing dataset when doing `check` and
1589              `zakat` operations.
1590
1591        Example Usage:
1592            The CSV file should have the following format, rate is optional per transaction:
1593            account, desc, value, date, rate
1594            For example:
1595            safe-45, "Some text", 34872, 1988-06-30 00:00:00, 1
1596        """
1597        if debug:
1598            print('import_csv', f'debug={debug}')
1599        cache: list[int] = []
1600        try:
1601            with open(self.import_csv_cache_path(), "rb") as f:
1602                cache = pickle.load(f)
1603        except:
1604            pass
1605        date_formats = [
1606            "%Y-%m-%d %H:%M:%S",
1607            "%Y-%m-%dT%H:%M:%S",
1608            "%Y-%m-%dT%H%M%S",
1609            "%Y-%m-%d",
1610        ]
1611        created, found, bad = 0, 0, {}
1612        data: dict[int, list] = {}
1613        with open(path, newline='', encoding="utf-8") as f:
1614            i = 0
1615            for row in csv.reader(f, delimiter=','):
1616                i += 1
1617                hashed = hash(tuple(row))
1618                if hashed in cache:
1619                    found += 1
1620                    continue
1621                account = row[0]
1622                desc = row[1]
1623                value = float(row[2])
1624                rate = 1.0
1625                if row[4:5]:  # Empty list if index is out of range
1626                    rate = float(row[4])
1627                date: int = 0
1628                for time_format in date_formats:
1629                    try:
1630                        date = self.time(datetime.datetime.strptime(row[3], time_format))
1631                        break
1632                    except:
1633                        pass
1634                # TODO: not allowed for negative dates
1635                if date == 0 or value == 0:
1636                    bad[i] = row
1637                    continue
1638                if date not in data:
1639                    data[date] = []
1640                # TODO: If duplicated time with different accounts with the same amount it is an indicator of a transfer
1641                data[date].append((date, value, desc, account, rate, hashed))
1642
1643        if debug:
1644            print('import_csv', len(data))
1645
1646        def process(row, index=0):
1647            nonlocal created
1648            (date, value, desc, account, rate, hashed) = row
1649            date += index
1650            if rate > 1:
1651                self.exchange(account, created=date, rate=rate)
1652            if value > 0:
1653                self.track(value, desc, account, True, date)
1654            elif value < 0:
1655                self.sub(-value, desc, account, date)
1656            created += 1
1657            cache.append(hashed)
1658
1659        for date, rows in sorted(data.items()):
1660            len_rows = len(rows)
1661            if len_rows == 1:
1662                process(rows[0])
1663                continue
1664            if debug:
1665                print('-- Duplicated time detected', date, 'len', len_rows)
1666                print(rows)
1667                print('---------------------------------')
1668            for index, row in enumerate(rows):
1669                process(row, index)
1670        with open(self.import_csv_cache_path(), "wb") as f:
1671            pickle.dump(cache, f)
1672        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:
1678    @staticmethod
1679    def human_readable_size(size: float, decimal_places: int = 2) -> str:
1680        """
1681        Converts a size in bytes to a human-readable format (e.g., KB, MB, GB).
1682
1683        This function iterates through progressively larger units of information
1684        (B, KB, MB, GB, etc.) and divides the input size until it fits within a
1685        range that can be expressed with a reasonable number before the unit.
1686
1687        Parameters:
1688        size (float): The size in bytes to convert.
1689        decimal_places (int, optional): The number of decimal places to display
1690            in the result. Defaults to 2.
1691
1692        Returns:
1693        str: A string representation of the size in a human-readable format,
1694            rounded to the specified number of decimal places. For example:
1695                - "1.50 KB" (1536 bytes)
1696                - "23.00 MB" (24117248 bytes)
1697                - "1.23 GB" (1325899906 bytes)
1698        """
1699        if type(size) not in (float, int):
1700            raise TypeError("size must be a float or integer")
1701        if type(decimal_places) != int:
1702            raise TypeError("decimal_places must be an integer")
1703        for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']:
1704            if size < 1024.0:
1705                break
1706            size /= 1024.0
1707        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:
1709    @staticmethod
1710    def get_dict_size(obj: dict, seen: set = None) -> float:
1711        """
1712        Recursively calculates the approximate memory size of a dictionary and its contents in bytes.
1713
1714        This function traverses the dictionary structure, accounting for the size of keys, values,
1715        and any nested objects. It handles various data types commonly found in dictionaries
1716        (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case
1717        of circular references.
1718
1719        Parameters:
1720        obj (dict): The dictionary whose size is to be calculated.
1721        seen (set, optional): A set used internally to track visited objects
1722                             and avoid circular references. Defaults to None.
1723
1724        Returns:
1725            float: An approximate size of the dictionary and its contents in bytes.
1726
1727        Note:
1728        - This function is a method of the `ZakatTracker` class and is likely used to
1729          estimate the memory footprint of data structures relevant to Zakat calculations.
1730        - The size calculation is approximate as it relies on `sys.getsizeof()`, which might
1731          not account for all memory overhead depending on the Python implementation.
1732        - Circular references are handled to prevent infinite recursion.
1733        - Basic numeric types (int, float, complex) are assumed to have fixed sizes.
1734        - String sizes are estimated based on character length and encoding.
1735        """
1736        size = 0
1737        if seen is None:
1738            seen = set()
1739
1740        obj_id = id(obj)
1741        if obj_id in seen:
1742            return 0
1743
1744        seen.add(obj_id)
1745        size += sys.getsizeof(obj)
1746
1747        if isinstance(obj, dict):
1748            for k, v in obj.items():
1749                size += ZakatTracker.get_dict_size(k, seen)
1750                size += ZakatTracker.get_dict_size(v, seen)
1751        elif isinstance(obj, (list, tuple, set, frozenset)):
1752            for item in obj:
1753                size += ZakatTracker.get_dict_size(item, seen)
1754        elif isinstance(obj, (int, float, complex)):  # Handle numbers
1755            pass  # Basic numbers have a fixed size, so nothing to add here
1756        elif isinstance(obj, str):  # Handle strings
1757            size += len(obj) * sys.getsizeof(str().encode())  # Size per character in bytes
1758        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:
1760    @staticmethod
1761    def duration_from_nanoseconds(ns: int,
1762                                  show_zeros_in_spoken_time: bool = False,
1763                                  spoken_time_separator=',',
1764                                  millennia: str = 'Millennia',
1765                                  century: str = 'Century',
1766                                  years: str = 'Years',
1767                                  days: str = 'Days',
1768                                  hours: str = 'Hours',
1769                                  minutes: str = 'Minutes',
1770                                  seconds: str = 'Seconds',
1771                                  milli_seconds: str = 'MilliSeconds',
1772                                  micro_seconds: str = 'MicroSeconds',
1773                                  nano_seconds: str = 'NanoSeconds',
1774                                  ) -> tuple:
1775        """
1776        REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
1777        Convert NanoSeconds to Human Readable Time Format.
1778        A NanoSeconds is a unit of time in the International System of Units (SI) equal
1779        to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
1780        Its symbol is μs, sometimes simplified to us when Unicode is not available.
1781        A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
1782
1783        INPUT : ms (AKA: MilliSeconds)
1784        OUTPUT: tuple(string time_lapsed, string spoken_time) like format.
1785        OUTPUT Variables: time_lapsed, spoken_time
1786
1787        Example  Input: duration_from_nanoseconds(ns)
1788        **"Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds"**
1789        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')
1790        duration_from_nanoseconds(1234567890123456789012)
1791        """
1792        us, ns = divmod(ns, 1000)
1793        ms, us = divmod(us, 1000)
1794        s, ms = divmod(ms, 1000)
1795        m, s = divmod(s, 60)
1796        h, m = divmod(m, 60)
1797        d, h = divmod(h, 24)
1798        y, d = divmod(d, 365)
1799        c, y = divmod(y, 100)
1800        n, c = divmod(c, 10)
1801        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}"
1802        spoken_time_part = []
1803        if n > 0 or show_zeros_in_spoken_time:
1804            spoken_time_part.append(f"{n: 3d} {millennia}")
1805        if c > 0 or show_zeros_in_spoken_time:
1806            spoken_time_part.append(f"{c: 4d} {century}")
1807        if y > 0 or show_zeros_in_spoken_time:
1808            spoken_time_part.append(f"{y: 3d} {years}")
1809        if d > 0 or show_zeros_in_spoken_time:
1810            spoken_time_part.append(f"{d: 4d} {days}")
1811        if h > 0 or show_zeros_in_spoken_time:
1812            spoken_time_part.append(f"{h: 2d} {hours}")
1813        if m > 0 or show_zeros_in_spoken_time:
1814            spoken_time_part.append(f"{m: 2d} {minutes}")
1815        if s > 0 or show_zeros_in_spoken_time:
1816            spoken_time_part.append(f"{s: 2d} {seconds}")
1817        if ms > 0 or show_zeros_in_spoken_time:
1818            spoken_time_part.append(f"{ms: 3d} {milli_seconds}")
1819        if us > 0 or show_zeros_in_spoken_time:
1820            spoken_time_part.append(f"{us: 3d} {micro_seconds}")
1821        if ns > 0 or show_zeros_in_spoken_time:
1822            spoken_time_part.append(f"{ns: 3d} {nano_seconds}")
1823        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:
1825    @staticmethod
1826    def day_to_time(day: int, month: int = 6, year: int = 2024) -> int:  # افتراض أن الشهر هو يونيو والسنة 2024
1827        """
1828        Convert a specific day, month, and year into a timestamp.
1829
1830        Parameters:
1831        day (int): The day of the month.
1832        month (int): The month of the year. Default is 6 (June).
1833        year (int): The year. Default is 2024.
1834
1835        Returns:
1836        int: The timestamp representing the given day, month, and year.
1837
1838        Note:
1839        This method assumes the default month and year if not provided.
1840        """
1841        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:
1843    @staticmethod
1844    def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
1845        """
1846        Generate a random date between two given dates.
1847
1848        Parameters:
1849        start_date (datetime.datetime): The start date from which to generate a random date.
1850        end_date (datetime.datetime): The end date until which to generate a random date.
1851
1852        Returns:
1853        datetime.datetime: A random date between the start_date and end_date.
1854        """
1855        time_between_dates = end_date - start_date
1856        days_between_dates = time_between_dates.days
1857        random_number_of_days = random.randrange(days_between_dates)
1858        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:
1860    @staticmethod
1861    def generate_random_csv_file(path: str = "data.csv", count: int = 1000, with_rate: bool = False,
1862                                 debug: bool = False) -> int:
1863        """
1864        Generate a random CSV file with specified parameters.
1865
1866        Parameters:
1867        path (str): The path where the CSV file will be saved. Default is "data.csv".
1868        count (int): The number of rows to generate in the CSV file. Default is 1000.
1869        with_rate (bool): If True, a random rate between 1.2% and 12% is added. Default is False.
1870        debug (bool): A flag indicating whether to print debug information.
1871
1872        Returns:
1873        None. The function generates a CSV file at the specified path with the given count of rows.
1874        Each row contains a randomly generated account, description, value, and date.
1875        The value is randomly generated between 1000 and 100000,
1876        and the date is randomly generated between 1950-01-01 and 2023-12-31.
1877        If the row number is not divisible by 13, the value is multiplied by -1.
1878        """
1879        if debug:
1880            print('generate_random_csv_file', f'debug={debug}')
1881        i = 0
1882        with open(path, "w", newline="") as csvfile:
1883            writer = csv.writer(csvfile)
1884            for i in range(count):
1885                account = f"acc-{random.randint(1, 1000)}"
1886                desc = f"Some text {random.randint(1, 1000)}"
1887                value = random.randint(1000, 100000)
1888                date = ZakatTracker.generate_random_date(datetime.datetime(1950, 1, 1),
1889                                                         datetime.datetime(2023, 12, 31)).strftime("%Y-%m-%d %H:%M:%S")
1890                if not i % 13 == 0:
1891                    value *= -1
1892                row = [account, desc, value, date]
1893                if with_rate:
1894                    rate = random.randint(1, 100) * 0.12
1895                    if debug:
1896                        print('before-append', row)
1897                    row.append(rate)
1898                    if debug:
1899                        print('after-append', row)
1900                writer.writerow(row)
1901                i = i + 1
1902        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):
1904    @staticmethod
1905    def create_random_list(max_sum, min_value=0, max_value=10):
1906        """
1907        Creates a list of random integers whose sum does not exceed the specified maximum.
1908
1909        Args:
1910            max_sum: The maximum allowed sum of the list elements.
1911            min_value: The minimum possible value for an element (inclusive).
1912            max_value: The maximum possible value for an element (inclusive).
1913
1914        Returns:
1915            A list of random integers.
1916        """
1917        result = []
1918        current_sum = 0
1919
1920        while current_sum < max_sum:
1921            # Calculate the remaining space for the next element
1922            remaining_sum = max_sum - current_sum
1923            # Determine the maximum possible value for the next element
1924            next_max_value = min(remaining_sum, max_value)
1925            # Generate a random element within the allowed range
1926            next_element = random.randint(min_value, next_max_value)
1927            result.append(next_element)
1928            current_sum += next_element
1929
1930        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:
2113    def test(self, debug: bool = False) -> bool:
2114        if debug:
2115            print('test', f'debug={debug}')
2116        try:
2117
2118            assert self._history()
2119
2120            # Not allowed for duplicate transactions in the same account and time
2121
2122            created = ZakatTracker.time()
2123            self.track(100, 'test-1', 'same', True, created)
2124            failed = False
2125            try:
2126                self.track(50, 'test-1', 'same', True, created)
2127            except:
2128                failed = True
2129            assert failed is True
2130
2131            self.reset()
2132
2133            # Same account transfer
2134            for x in [1, 'a', True, 1.8, None]:
2135                failed = False
2136                try:
2137                    self.transfer(1, x, x, 'same-account', debug=debug)
2138                except:
2139                    failed = True
2140                assert failed is True
2141
2142            # Always preserve box age during transfer
2143
2144            series: list[tuple] = [
2145                (30, 4),
2146                (60, 3),
2147                (90, 2),
2148            ]
2149            case = {
2150                30: {
2151                    'series': series,
2152                    'rest': 150,
2153                },
2154                60: {
2155                    'series': series,
2156                    'rest': 120,
2157                },
2158                90: {
2159                    'series': series,
2160                    'rest': 90,
2161                },
2162                180: {
2163                    'series': series,
2164                    'rest': 0,
2165                },
2166                270: {
2167                    'series': series,
2168                    'rest': -90,
2169                },
2170                360: {
2171                    'series': series,
2172                    'rest': -180,
2173                },
2174            }
2175
2176            selected_time = ZakatTracker.time() - ZakatTracker.TimeCycle()
2177
2178            for total in case:
2179                for x in case[total]['series']:
2180                    self.track(x[0], f"test-{x} ages", 'ages', True, selected_time * x[1])
2181
2182                refs = self.transfer(total, 'ages', 'future', 'Zakat Movement', debug=debug)
2183
2184                if debug:
2185                    print('refs', refs)
2186
2187                ages_cache_balance = self.balance('ages')
2188                ages_fresh_balance = self.balance('ages', False)
2189                rest = case[total]['rest']
2190                if debug:
2191                    print('source', ages_cache_balance, ages_fresh_balance, rest)
2192                assert ages_cache_balance == rest
2193                assert ages_fresh_balance == rest
2194
2195                future_cache_balance = self.balance('future')
2196                future_fresh_balance = self.balance('future', False)
2197                if debug:
2198                    print('target', future_cache_balance, future_fresh_balance, total)
2199                    print('refs', refs)
2200                assert future_cache_balance == total
2201                assert future_fresh_balance == total
2202
2203                for ref in self._vault['account']['ages']['box']:
2204                    ages_capital = self._vault['account']['ages']['box'][ref]['capital']
2205                    ages_rest = self._vault['account']['ages']['box'][ref]['rest']
2206                    future_capital = 0
2207                    if ref in self._vault['account']['future']['box']:
2208                        future_capital = self._vault['account']['future']['box'][ref]['capital']
2209                    future_rest = 0
2210                    if ref in self._vault['account']['future']['box']:
2211                        future_rest = self._vault['account']['future']['box'][ref]['rest']
2212                    if ages_capital != 0 and future_capital != 0 and future_rest != 0:
2213                        if debug:
2214                            print('================================================================')
2215                            print('ages', ages_capital, ages_rest)
2216                            print('future', future_capital, future_rest)
2217                        if ages_rest == 0:
2218                            assert ages_capital == future_capital
2219                        elif ages_rest < 0:
2220                            assert -ages_capital == future_capital
2221                        elif ages_rest > 0:
2222                            assert ages_capital == ages_rest + future_capital
2223                self.reset()
2224                assert len(self._vault['history']) == 0
2225
2226            assert self._history()
2227            assert self._history(False) is False
2228            assert self._history() is False
2229            assert self._history(True)
2230            assert self._history()
2231
2232            self._test_core(True, debug)
2233            self._test_core(False, debug)
2234
2235            transaction = [
2236                (
2237                    20, 'wallet', 1, 800, 800, 800, 4, 5,
2238                    -85, -85, -85, 6, 7,
2239                ),
2240                (
2241                    750, 'wallet', 'safe', 50, 50, 50, 4, 6,
2242                    750, 750, 750, 1, 1,
2243                ),
2244                (
2245                    600, 'safe', 'bank', 150, 150, 150, 1, 2,
2246                    600, 600, 600, 1, 1,
2247                ),
2248            ]
2249            for z in transaction:
2250                self.lock()
2251                x = z[1]
2252                y = z[2]
2253                self.transfer(z[0], x, y, 'test-transfer', debug=debug)
2254                assert self.balance(x) == z[3]
2255                xx = self.accounts()[x]
2256                assert xx == z[3]
2257                assert self.balance(x, False) == z[4]
2258                assert xx == z[4]
2259
2260                s = 0
2261                log = self._vault['account'][x]['log']
2262                for i in log:
2263                    s += log[i]['value']
2264                if debug:
2265                    print('s', s, 'z[5]', z[5])
2266                assert s == z[5]
2267
2268                assert self.box_size(x) == z[6]
2269                assert self.log_size(x) == z[7]
2270
2271                yy = self.accounts()[y]
2272                assert self.balance(y) == z[8]
2273                assert yy == z[8]
2274                assert self.balance(y, False) == z[9]
2275                assert yy == z[9]
2276
2277                s = 0
2278                log = self._vault['account'][y]['log']
2279                for i in log:
2280                    s += log[i]['value']
2281                assert s == z[10]
2282
2283                assert self.box_size(y) == z[11]
2284                assert self.log_size(y) == z[12]
2285
2286            if debug:
2287                pp().pprint(self.check(2.17))
2288
2289            assert not self.nolock()
2290            history_count = len(self._vault['history'])
2291            if debug:
2292                print('history-count', history_count)
2293            assert history_count == 11
2294            assert not self.free(ZakatTracker.time())
2295            assert self.free(self.lock())
2296            assert self.nolock()
2297            assert len(self._vault['history']) == 11
2298
2299            # storage
2300
2301            _path = self.path('test.pickle')
2302            if os.path.exists(_path):
2303                os.remove(_path)
2304            self.save()
2305            assert os.path.getsize(_path) > 0
2306            self.reset()
2307            assert self.recall(False, debug) is False
2308            self.load()
2309            assert self._vault['account'] is not None
2310
2311            # recall
2312
2313            assert self.nolock()
2314            assert len(self._vault['history']) == 11
2315            assert self.recall(False, debug) is True
2316            assert len(self._vault['history']) == 10
2317            assert self.recall(False, debug) is True
2318            assert len(self._vault['history']) == 9
2319
2320            # exchange
2321
2322            self.exchange("cash", 25, 3.75, "2024-06-25")
2323            self.exchange("cash", 22, 3.73, "2024-06-22")
2324            self.exchange("cash", 15, 3.69, "2024-06-15")
2325            self.exchange("cash", 10, 3.66)
2326
2327            for i in range(1, 30):
2328                exchange = self.exchange("cash", i)
2329                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2330                if debug:
2331                    print(i, rate, description, created)
2332                assert created
2333                if i < 10:
2334                    assert rate == 1
2335                    assert description is None
2336                elif i == 10:
2337                    assert rate == 3.66
2338                    assert description is None
2339                elif i < 15:
2340                    assert rate == 3.66
2341                    assert description is None
2342                elif i == 15:
2343                    assert rate == 3.69
2344                    assert description is not None
2345                elif i < 22:
2346                    assert rate == 3.69
2347                    assert description is not None
2348                elif i == 22:
2349                    assert rate == 3.73
2350                    assert description is not None
2351                elif i >= 25:
2352                    assert rate == 3.75
2353                    assert description is not None
2354                exchange = self.exchange("bank", i)
2355                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2356                if debug:
2357                    print(i, rate, description, created)
2358                assert created
2359                assert rate == 1
2360                assert description is None
2361
2362            assert len(self._vault['exchange']) > 0
2363            assert len(self.exchanges()) > 0
2364            self._vault['exchange'].clear()
2365            assert len(self._vault['exchange']) == 0
2366            assert len(self.exchanges()) == 0
2367
2368            # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية
2369            self.exchange("cash", ZakatTracker.day_to_time(25), 3.75, "2024-06-25")
2370            self.exchange("cash", ZakatTracker.day_to_time(22), 3.73, "2024-06-22")
2371            self.exchange("cash", ZakatTracker.day_to_time(15), 3.69, "2024-06-15")
2372            self.exchange("cash", ZakatTracker.day_to_time(10), 3.66)
2373
2374            for i in [x * 0.12 for x in range(-15, 21)]:
2375                if i <= 0:
2376                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) == 0
2377                else:
2378                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) > 0
2379
2380            # اختبار النتائج باستخدام التواريخ بالنانو ثانية
2381            for i in range(1, 31):
2382                timestamp_ns = ZakatTracker.day_to_time(i)
2383                exchange = self.exchange("cash", timestamp_ns)
2384                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2385                if debug:
2386                    print(i, rate, description, created)
2387                assert created
2388                if i < 10:
2389                    assert rate == 1
2390                    assert description is None
2391                elif i == 10:
2392                    assert rate == 3.66
2393                    assert description is None
2394                elif i < 15:
2395                    assert rate == 3.66
2396                    assert description is None
2397                elif i == 15:
2398                    assert rate == 3.69
2399                    assert description is not None
2400                elif i < 22:
2401                    assert rate == 3.69
2402                    assert description is not None
2403                elif i == 22:
2404                    assert rate == 3.73
2405                    assert description is not None
2406                elif i >= 25:
2407                    assert rate == 3.75
2408                    assert description is not None
2409                exchange = self.exchange("bank", i)
2410                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2411                if debug:
2412                    print(i, rate, description, created)
2413                assert created
2414                assert rate == 1
2415                assert description is None
2416
2417            # csv
2418
2419            csv_count = 1000
2420
2421            for with_rate, path in {
2422                False: 'test-import_csv-no-exchange',
2423                True: 'test-import_csv-with-exchange',
2424            }.items():
2425
2426                if debug:
2427                    print('test_import_csv', with_rate, path)
2428
2429                # csv
2430
2431                csv_path = path + '.csv'
2432                if os.path.exists(csv_path):
2433                    os.remove(csv_path)
2434                c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug)
2435                if debug:
2436                    print('generate_random_csv_file', c)
2437                assert c == csv_count
2438                assert os.path.getsize(csv_path) > 0
2439                cache_path = self.import_csv_cache_path()
2440                if os.path.exists(cache_path):
2441                    os.remove(cache_path)
2442                self.reset()
2443                (created, found, bad) = self.import_csv(csv_path, debug)
2444                bad_count = len(bad)
2445                if debug:
2446                    print(f"csv-imported: ({created}, {found}, {bad_count}) = count({csv_count})")
2447                tmp_size = os.path.getsize(cache_path)
2448                assert tmp_size > 0
2449                assert created + found + bad_count == csv_count
2450                assert created == csv_count
2451                assert bad_count == 0
2452                (created_2, found_2, bad_2) = self.import_csv(csv_path)
2453                bad_2_count = len(bad_2)
2454                if debug:
2455                    print(f"csv-imported: ({created_2}, {found_2}, {bad_2_count})")
2456                    print(bad)
2457                assert tmp_size == os.path.getsize(cache_path)
2458                assert created_2 + found_2 + bad_2_count == csv_count
2459                assert created == found_2
2460                assert bad_count == bad_2_count
2461                assert found_2 == csv_count
2462                assert bad_2_count == 0
2463                assert created_2 == 0
2464
2465                # payment parts
2466
2467                positive_parts = self.build_payment_parts(100, positive_only=True)
2468                assert self.check_payment_parts(positive_parts) != 0
2469                assert self.check_payment_parts(positive_parts) != 0
2470                all_parts = self.build_payment_parts(300, positive_only=False)
2471                assert self.check_payment_parts(all_parts) != 0
2472                assert self.check_payment_parts(all_parts) != 0
2473                if debug:
2474                    pp().pprint(positive_parts)
2475                    pp().pprint(all_parts)
2476                # dynamic discount
2477                suite = []
2478                count = 3
2479                for exceed in [False, True]:
2480                    case = []
2481                    for parts in [positive_parts, all_parts]:
2482                        part = parts.copy()
2483                        demand = part['demand']
2484                        if debug:
2485                            print(demand, part['total'])
2486                        i = 0
2487                        z = demand / count
2488                        cp = {
2489                            'account': {},
2490                            'demand': demand,
2491                            'exceed': exceed,
2492                            'total': part['total'],
2493                        }
2494                        j = ''
2495                        for x, y in part['account'].items():
2496                            x_exchange = self.exchange(x)
2497                            zz = self.exchange_calc(z, 1, x_exchange['rate'])
2498                            if exceed and zz <= demand:
2499                                i += 1
2500                                y['part'] = zz
2501                                if debug:
2502                                    print(exceed, y)
2503                                cp['account'][x] = y
2504                                case.append(y)
2505                            elif not exceed and y['balance'] >= zz:
2506                                i += 1
2507                                y['part'] = zz
2508                                if debug:
2509                                    print(exceed, y)
2510                                cp['account'][x] = y
2511                                case.append(y)
2512                            j = x
2513                            if i >= count:
2514                                break
2515                        if len(cp['account'][j]) > 0:
2516                            suite.append(cp)
2517                if debug:
2518                    print('suite', len(suite))
2519                # vault = self._vault.copy()
2520                for case in suite:
2521                    # self._vault = vault.copy()
2522                    if debug:
2523                        print('case', case)
2524                    result = self.check_payment_parts(case)
2525                    if debug:
2526                        print('check_payment_parts', result, f'exceed: {exceed}')
2527                    assert result == 0
2528
2529                    report = self.check(2.17, None, debug)
2530                    (valid, brief, plan) = report
2531                    if debug:
2532                        print('valid', valid)
2533                    zakat_result = self.zakat(report, parts=case, debug=debug)
2534                    if debug:
2535                        print('zakat-result', zakat_result)
2536                    assert valid == zakat_result
2537
2538            assert self.save(path + '.pickle')
2539            assert self.export_json(path + '.json')
2540
2541            assert self.export_json("1000-transactions-test.json")
2542            assert self.save("1000-transactions-test.pickle")
2543
2544            self.reset()
2545
2546            # test transfer between accounts with different exchange rate
2547
2548            a_SAR = "Bank (SAR)"
2549            b_USD = "Bank (USD)"
2550            c_SAR = "Safe (SAR)"
2551            # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer
2552            for case in [
2553                (0, a_SAR, "SAR Gift", 1000, 1000),
2554                (1, a_SAR, 1),
2555                (0, b_USD, "USD Gift", 500, 500),
2556                (1, b_USD, 1),
2557                (2, b_USD, 3.75),
2558                (1, b_USD, 3.75),
2559                (3, 100, b_USD, a_SAR, "100 USD -> SAR", 400, 1375),
2560                (0, c_SAR, "Salary", 750, 750),
2561                (3, 375, c_SAR, b_USD, "375 SAR -> USD", 375, 500),
2562                (3, 3.75, a_SAR, b_USD, "3.75 SAR -> USD", 1371.25, 501),
2563            ]:
2564                match (case[0]):
2565                    case 0:  # track
2566                        _, account, desc, x, balance = case
2567                        self.track(value=x, desc=desc, account=account, debug=debug)
2568
2569                        cached_value = self.balance(account, cached=True)
2570                        fresh_value = self.balance(account, cached=False)
2571                        if debug:
2572                            print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value)
2573                        assert cached_value == balance
2574                        assert fresh_value == balance
2575                    case 1:  # check-exchange
2576                        _, account, expected_rate = case
2577                        t_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
2578                        if debug:
2579                            print('t-exchange', t_exchange)
2580                        assert t_exchange['rate'] == expected_rate
2581                    case 2:  # do-exchange
2582                        _, account, rate = case
2583                        self.exchange(account, rate=rate, debug=debug)
2584                        b_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
2585                        if debug:
2586                            print('b-exchange', b_exchange)
2587                        assert b_exchange['rate'] == rate
2588                    case 3:  # transfer
2589                        _, x, a, b, desc, a_balance, b_balance = case
2590                        self.transfer(x, a, b, desc, debug=debug)
2591
2592                        cached_value = self.balance(a, cached=True)
2593                        fresh_value = self.balance(a, cached=False)
2594                        if debug:
2595                            print('account', a, 'cached_value', cached_value, 'fresh_value', fresh_value)
2596                        assert cached_value == a_balance
2597                        assert fresh_value == a_balance
2598
2599                        cached_value = self.balance(b, cached=True)
2600                        fresh_value = self.balance(b, cached=False)
2601                        if debug:
2602                            print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value)
2603                        assert cached_value == b_balance
2604                        assert fresh_value == b_balance
2605
2606            # Transfer all in many chunks randomly from B to A
2607            a_SAR_balance = 1371.25
2608            b_USD_balance = 501
2609            b_USD_exchange = self.exchange(b_USD)
2610            amounts = ZakatTracker.create_random_list(b_USD_balance)
2611            if debug:
2612                print('amounts', amounts)
2613            i = 0
2614            for x in amounts:
2615                if debug:
2616                    print(f'{i} - transfer-with-exchange({x})')
2617                self.transfer(x, b_USD, a_SAR, f"{x} USD -> SAR", debug=debug)
2618
2619                b_USD_balance -= x
2620                cached_value = self.balance(b_USD, cached=True)
2621                fresh_value = self.balance(b_USD, cached=False)
2622                if debug:
2623                    print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
2624                          b_USD_balance)
2625                assert cached_value == b_USD_balance
2626                assert fresh_value == b_USD_balance
2627
2628                a_SAR_balance += x * b_USD_exchange['rate']
2629                cached_value = self.balance(a_SAR, cached=True)
2630                fresh_value = self.balance(a_SAR, cached=False)
2631                if debug:
2632                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
2633                          a_SAR_balance, 'rate', b_USD_exchange['rate'])
2634                assert cached_value == a_SAR_balance
2635                assert fresh_value == a_SAR_balance
2636                i += 1
2637
2638            # Transfer all in many chunks randomly from C to A
2639            c_SAR_balance = 375
2640            amounts = ZakatTracker.create_random_list(c_SAR_balance)
2641            if debug:
2642                print('amounts', amounts)
2643            i = 0
2644            for x in amounts:
2645                if debug:
2646                    print(f'{i} - transfer-with-exchange({x})')
2647                self.transfer(x, c_SAR, a_SAR, f"{x} SAR -> a_SAR", debug=debug)
2648
2649                c_SAR_balance -= x
2650                cached_value = self.balance(c_SAR, cached=True)
2651                fresh_value = self.balance(c_SAR, cached=False)
2652                if debug:
2653                    print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
2654                          c_SAR_balance)
2655                assert cached_value == c_SAR_balance
2656                assert fresh_value == c_SAR_balance
2657
2658                a_SAR_balance += x
2659                cached_value = self.balance(a_SAR, cached=True)
2660                fresh_value = self.balance(a_SAR, cached=False)
2661                if debug:
2662                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
2663                          a_SAR_balance)
2664                assert cached_value == a_SAR_balance
2665                assert fresh_value == a_SAR_balance
2666                i += 1
2667
2668            assert self.export_json("accounts-transfer-with-exchange-rates.json")
2669            assert self.save("accounts-transfer-with-exchange-rates.pickle")
2670
2671            # check & zakat with exchange rates for many cycles
2672
2673            for rate, values in {
2674                1: {
2675                    'in': [1000, 2000, 10000],
2676                    'exchanged': [1000, 2000, 10000],
2677                    'out': [25, 50, 731.40625],
2678                },
2679                3.75: {
2680                    'in': [200, 1000, 5000],
2681                    'exchanged': [750, 3750, 18750],
2682                    'out': [18.75, 93.75, 1371.38671875],
2683                },
2684            }.items():
2685                a, b, c = values['in']
2686                m, n, o = values['exchanged']
2687                x, y, z = values['out']
2688                if debug:
2689                    print('rate', rate, 'values', values)
2690                for case in [
2691                    (a, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
2692                        {'safe': {0: {'below_nisab': x}}},
2693                    ], False, m),
2694                    (b, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
2695                        {'safe': {0: {'count': 1, 'total': y}}},
2696                    ], True, n),
2697                    (c, 'cave', ZakatTracker.time() - (ZakatTracker.TimeCycle() * 3), [
2698                        {'cave': {0: {'count': 3, 'total': z}}},
2699                    ], True, o),
2700                ]:
2701                    if debug:
2702                        print(f"############# check(rate: {rate}) #############")
2703                    self.reset()
2704                    self.exchange(account=case[1], created=case[2], rate=rate)
2705                    self.track(value=case[0], desc='test-check', account=case[1], logging=True, created=case[2])
2706
2707                    # assert self.nolock()
2708                    # history_size = len(self._vault['history'])
2709                    # print('history_size', history_size)
2710                    # assert history_size == 2
2711                    assert self.lock()
2712                    assert not self.nolock()
2713                    report = self.check(2.17, None, debug)
2714                    (valid, brief, plan) = report
2715                    assert valid == case[4]
2716                    if debug:
2717                        print('brief', brief)
2718                    assert case[5] == brief[0]
2719                    assert case[5] == brief[1]
2720
2721                    if debug:
2722                        pp().pprint(plan)
2723
2724                    for x in plan:
2725                        assert case[1] == x
2726                        if 'total' in case[3][0][x][0].keys():
2727                            assert case[3][0][x][0]['total'] == brief[2]
2728                            assert plan[x][0]['total'] == case[3][0][x][0]['total']
2729                            assert plan[x][0]['count'] == case[3][0][x][0]['count']
2730                        else:
2731                            assert plan[x][0]['below_nisab'] == case[3][0][x][0]['below_nisab']
2732                    if debug:
2733                        pp().pprint(report)
2734                    result = self.zakat(report, debug=debug)
2735                    if debug:
2736                        print('zakat-result', result, case[4])
2737                    assert result == case[4]
2738                    report = self.check(2.17, None, debug)
2739                    (valid, brief, plan) = report
2740                    assert valid is False
2741
2742            history_size = len(self._vault['history'])
2743            if debug:
2744                print('history_size', history_size)
2745            assert history_size == 3
2746            assert not self.nolock()
2747            assert self.recall(False, debug) is False
2748            self.free(self.lock())
2749            assert self.nolock()
2750
2751            for i in range(3, 0, -1):
2752                history_size = len(self._vault['history'])
2753                if debug:
2754                    print('history_size', history_size)
2755                assert history_size == i
2756                assert self.recall(False, debug) is True
2757
2758            assert self.nolock()
2759            assert self.recall(False, debug) is False
2760
2761            history_size = len(self._vault['history'])
2762            if debug:
2763                print('history_size', history_size)
2764            assert history_size == 0
2765
2766            account_size = len(self._vault['account'])
2767            if debug:
2768                print('account_size', account_size)
2769            assert account_size == 0
2770
2771            report_size = len(self._vault['report'])
2772            if debug:
2773                print('report_size', report_size)
2774            assert report_size == 0
2775
2776            assert self.nolock()
2777            return True
2778        except:
2779            # pp().pprint(self._vault)
2780            assert self.export_json("test-snapshot.json")
2781            assert self.save("test-snapshot.pickle")
2782            raise
def test(debug: bool = False):
2785def test(debug: bool = False):
2786    ledger = ZakatTracker()
2787    start = ZakatTracker.time()
2788    assert ledger.test(debug=debug)
2789    if debug:
2790        print("#########################")
2791        print("######## TEST DONE ########")
2792        print("#########################")
2793        print(ZakatTracker.duration_from_nanoseconds(ZakatTracker.time() - start))
2794        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'>