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

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

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:
301    @staticmethod
302    def time(now: datetime = None) -> int:
303        """
304        Generates a timestamp based on the provided datetime object or the current datetime.
305
306        Parameters:
307        now (datetime, optional): The datetime object to generate the timestamp from.
308        If not provided, the current datetime is used.
309
310        Returns:
311        int: The timestamp in positive nanoseconds since the Unix epoch (January 1, 1970),
312            before 1970 will return in negative until 1000AD.
313        """
314        if now is None:
315            now = datetime.datetime.now()
316        ordinal_day = now.toordinal()
317        ns_in_day = (now - now.replace(hour=0, minute=0, second=0, microsecond=0)).total_seconds() * 10 ** 9
318        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'>:
320    @staticmethod
321    def time_to_datetime(ordinal_ns: int) -> datetime:
322        """
323        Converts an ordinal number (number of days since 1000-01-01) to a datetime object.
324
325        Parameters:
326        ordinal_ns (int): The ordinal number of days since 1000-01-01.
327
328        Returns:
329        datetime: The corresponding datetime object.
330        """
331        ordinal_day = ordinal_ns // 86_400_000_000_000 + 719_163
332        ns_in_day = ordinal_ns % 86_400_000_000_000
333        d = datetime.datetime.fromordinal(ordinal_day)
334        t = datetime.timedelta(seconds=ns_in_day // 10 ** 9)
335        return datetime.datetime.combine(d, datetime.time()) + t

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

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

Returns: datetime: The corresponding datetime object.

def nolock(self) -> bool:
373    def nolock(self) -> bool:
374        """
375        Check if the vault lock is currently not set.
376
377        Returns:
378        bool: True if the vault lock is not set, False otherwise.
379        """
380        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:
382    def lock(self) -> int:
383        """
384        Acquires a lock on the ZakatTracker instance.
385
386        Returns:
387        int: The lock ID. This ID can be used to release the lock later.
388        """
389        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 box(self) -> dict:
391    def box(self) -> dict:
392        """
393        Returns a copy of the internal vault dictionary.
394
395        This method is used to retrieve the current state of the ZakatTracker object.
396        It provides a snapshot of the internal data structure, allowing for further
397        processing or analysis.
398
399        Returns:
400        dict: A copy of the internal vault dictionary.
401        """
402        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 steps(self) -> dict:
404    def steps(self) -> dict:
405        """
406        Returns a copy of the history of steps taken in the ZakatTracker.
407
408        The history is a dictionary where each key is a unique identifier for a step,
409        and the corresponding value is a dictionary containing information about the step.
410
411        Returns:
412        dict: A copy of the history of steps taken in the ZakatTracker.
413        """
414        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:
416    def free(self, lock: int, auto_save: bool = True) -> bool:
417        """
418        Releases the lock on the database.
419
420        Parameters:
421        lock (int): The lock ID to be released.
422        auto_save (bool): Whether to automatically save the database after releasing the lock.
423
424        Returns:
425        bool: True if the lock is successfully released and (optionally) saved, False otherwise.
426        """
427        if lock == self._vault['lock']:
428            self._vault['lock'] = None
429            if auto_save:
430                return self.save(self.path())
431            return True
432        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:
434    def account_exists(self, account) -> bool:
435        """
436        Check if the given account exists in the vault.
437
438        Parameters:
439        account (str): The account number to check.
440
441        Returns:
442        bool: True if the account exists, False otherwise.
443        """
444        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:
446    def box_size(self, account) -> int:
447        """
448        Calculate the size of the box for a specific account.
449
450        Parameters:
451        account (str): The account number for which the box size needs to be calculated.
452
453        Returns:
454        int: The size of the box for the given account. If the account does not exist, -1 is returned.
455        """
456        if self.account_exists(account):
457            return len(self._vault['account'][account]['box'])
458        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:
460    def log_size(self, account) -> int:
461        """
462        Get the size of the log for a specific account.
463
464        Parameters:
465        account (str): The account number for which the log size needs to be calculated.
466
467        Returns:
468        int: The size of the log for the given account. If the account does not exist, -1 is returned.
469        """
470        if self.account_exists(account):
471            return len(self._vault['account'][account]['log'])
472        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:
474    def recall(self, dry=True, debug=False) -> bool:
475        """
476        Revert the last operation.
477
478        Parameters:
479        dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
480        debug (bool): If True, the function will print debug information. Default is False.
481
482        Returns:
483        bool: True if the operation was successful, False otherwise.
484        """
485        if not self.nolock() or len(self._vault['history']) == 0:
486            return False
487        if len(self._vault['history']) <= 0:
488            return False
489        ref = sorted(self._vault['history'].keys())[-1]
490        if debug:
491            print('recall', ref)
492        memory = self._vault['history'][ref]
493        if debug:
494            print(type(memory), 'memory', memory)
495
496        limit = len(memory) + 1
497        sub_positive_log_negative = 0
498        for i in range(-1, -limit, -1):
499            x = memory[i]
500            if debug:
501                print(type(x), x)
502            match x['action']:
503                case Action.CREATE:
504                    if x['account'] is not None:
505                        if self.account_exists(x['account']):
506                            if debug:
507                                print('account', self._vault['account'][x['account']])
508                            assert len(self._vault['account'][x['account']]['box']) == 0
509                            assert self._vault['account'][x['account']]['balance'] == 0
510                            assert self._vault['account'][x['account']]['count'] == 0
511                            if dry:
512                                continue
513                            del self._vault['account'][x['account']]
514
515                case Action.TRACK:
516                    if x['account'] is not None:
517                        if self.account_exists(x['account']):
518                            if dry:
519                                continue
520                            self._vault['account'][x['account']]['balance'] -= x['value']
521                            self._vault['account'][x['account']]['count'] -= 1
522                            del self._vault['account'][x['account']]['box'][x['ref']]
523
524                case Action.LOG:
525                    if x['account'] is not None:
526                        if self.account_exists(x['account']):
527                            if x['ref'] in self._vault['account'][x['account']]['log']:
528                                if dry:
529                                    continue
530                                if sub_positive_log_negative == -x['value']:
531                                    self._vault['account'][x['account']]['count'] -= 1
532                                    sub_positive_log_negative = 0
533                                del self._vault['account'][x['account']]['log'][x['ref']]
534
535                case Action.SUB:
536                    if x['account'] is not None:
537                        if self.account_exists(x['account']):
538                            if x['ref'] in self._vault['account'][x['account']]['box']:
539                                if dry:
540                                    continue
541                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] += x['value']
542                                self._vault['account'][x['account']]['balance'] += x['value']
543                                sub_positive_log_negative = x['value']
544
545                case Action.ADD_FILE:
546                    if x['account'] is not None:
547                        if self.account_exists(x['account']):
548                            if x['ref'] in self._vault['account'][x['account']]['log']:
549                                if x['file'] in self._vault['account'][x['account']]['log'][x['ref']]['file']:
550                                    if dry:
551                                        continue
552                                    del self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']]
553
554                case Action.REMOVE_FILE:
555                    if x['account'] is not None:
556                        if self.account_exists(x['account']):
557                            if x['ref'] in self._vault['account'][x['account']]['log']:
558                                if dry:
559                                    continue
560                                self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']] = x['value']
561
562                case Action.BOX_TRANSFER:
563                    if x['account'] is not None:
564                        if self.account_exists(x['account']):
565                            if x['ref'] in self._vault['account'][x['account']]['box']:
566                                if dry:
567                                    continue
568                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] -= x['value']
569
570                case Action.EXCHANGE:
571                    if x['account'] is not None:
572                        if x['account'] in self._vault['exchange']:
573                            if x['ref'] in self._vault['exchange'][x['account']]:
574                                if dry:
575                                    continue
576                                del self._vault['exchange'][x['account']][x['ref']]
577
578                case Action.REPORT:
579                    if x['ref'] in self._vault['report']:
580                        if dry:
581                            continue
582                        del self._vault['report'][x['ref']]
583
584                case Action.ZAKAT:
585                    if x['account'] is not None:
586                        if self.account_exists(x['account']):
587                            if x['ref'] in self._vault['account'][x['account']]['box']:
588                                if x['key'] in self._vault['account'][x['account']]['box'][x['ref']]:
589                                    if dry:
590                                        continue
591                                    match x['math']:
592                                        case MathOperation.ADDITION:
593                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] -= x[
594                                                'value']
595                                        case MathOperation.EQUAL:
596                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] = x['value']
597                                        case MathOperation.SUBTRACTION:
598                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] += x[
599                                                'value']
600
601        if not dry:
602            del self._vault['history'][ref]
603        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:
605    def ref_exists(self, account: str, ref_type: str, ref: int) -> bool:
606        """
607        Check if a specific reference (transaction) exists in the vault for a given account and reference type.
608
609        Parameters:
610        account (str): The account number for which to check the existence of the reference.
611        ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
612        ref (int): The reference (transaction) number to check for existence.
613
614        Returns:
615        bool: True if the reference exists for the given account and reference type, False otherwise.
616        """
617        if account in self._vault['account']:
618            return ref in self._vault['account'][account][ref_type]
619        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:
621    def box_exists(self, account: str, ref: int) -> bool:
622        """
623        Check if a specific box (transaction) exists in the vault for a given account and reference.
624
625        Parameters:
626        - account (str): The account number for which to check the existence of the box.
627        - ref (int): The reference (transaction) number to check for existence.
628
629        Returns:
630        - bool: True if the box exists for the given account and reference, False otherwise.
631        """
632        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:
634    def track(self, value: float = 0, desc: str = '', account: str = 1, logging: bool = True, created: int = None,
635              debug: bool = False) -> int:
636        """
637        This function tracks a transaction for a specific account.
638
639        Parameters:
640        value (float): The value of the transaction. Default is 0.
641        desc (str): The description of the transaction. Default is an empty string.
642        account (str): The account for which the transaction is being tracked. Default is '1'.
643        logging (bool): Whether to log the transaction. Default is True.
644        created (int): The timestamp of the transaction. If not provided, it will be generated. Default is None.
645        debug (bool): Whether to print debug information. Default is False.
646
647        Returns:
648        int: The timestamp of the transaction.
649
650        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.
651
652        Raises:
653        ValueError: The log transaction happened again in the same nanosecond time.
654        ValueError: The box transaction happened again in the same nanosecond time.
655        """
656        if created is None:
657            created = self.time()
658        no_lock = self.nolock()
659        self.lock()
660        if not self.account_exists(account):
661            if debug:
662                print(f"account {account} created")
663            self._vault['account'][account] = {
664                'balance': 0,
665                'box': {},
666                'count': 0,
667                'log': {},
668                'hide': False,
669                'zakatable': True,
670            }
671            self._step(Action.CREATE, account)
672        if value == 0:
673            if no_lock:
674                self.free(self.lock())
675            return 0
676        if logging:
677            self._log(value, desc, account, created, debug)
678        if debug:
679            print('create-box', created)
680        if self.box_exists(account, created):
681            raise ValueError(f"The box transaction happened again in the same nanosecond time({created}).")
682        if debug:
683            print('created-box', created)
684        self._vault['account'][account]['box'][created] = {
685            'capital': value,
686            'count': 0,
687            'last': 0,
688            'rest': value,
689            'total': 0,
690        }
691        self._step(Action.TRACK, account, ref=created, value=value)
692        if no_lock:
693            self.free(self.lock())
694        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:
696    def log_exists(self, account: str, ref: int) -> bool:
697        """
698        Checks if a specific transaction log entry exists for a given account.
699
700        Parameters:
701        account (str): The account number associated with the transaction log.
702        ref (int): The reference to the transaction log entry.
703
704        Returns:
705        bool: True if the transaction log entry exists, False otherwise.
706        """
707        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:
749    def exchange(self, account, created: int = None, rate: float = None, description: str = None,
750                 debug: bool = False) -> dict:
751        """
752        This method is used to record or retrieve exchange rates for a specific account.
753
754        Parameters:
755        - account (str): The account number for which the exchange rate is being recorded or retrieved.
756        - created (int): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
757        - rate (float): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
758        - description (str): A description of the exchange rate.
759
760        Returns:
761        - dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found,
762        it returns a dictionary with default values for the rate and description.
763        """
764        if created is None:
765            created = self.time()
766        no_lock = self.nolock()
767        self.lock()
768        if rate is not None:
769            if rate <= 0:
770                return dict()
771            if account not in self._vault['exchange']:
772                self._vault['exchange'][account] = {}
773            if len(self._vault['exchange'][account]) == 0 and rate <= 1:
774                return {"time": created, "rate": 1, "description": None}
775            self._vault['exchange'][account][created] = {"rate": rate, "description": description}
776            self._step(Action.EXCHANGE, account, ref=created, value=rate)
777            if no_lock:
778                self.free(self.lock())
779            if debug:
780                print("exchange-created-1",
781                      f'account: {account}, created: {created}, rate:{rate}, description:{description}')
782
783        if account in self._vault['exchange']:
784            valid_rates = [(ts, r) for ts, r in self._vault['exchange'][account].items() if ts <= created]
785            if valid_rates:
786                latest_rate = max(valid_rates, key=lambda x: x[0])
787                if debug:
788                    print("exchange-read-1",
789                          f'account: {account}, created: {created}, rate:{rate}, description:{description}',
790                          'latest_rate', latest_rate)
791                result = latest_rate[1]
792                result['time'] = latest_rate[0]
793                return result  # إرجاع قاموس يحتوي على المعدل والوصف
794        if debug:
795            print("exchange-read-0", f'account: {account}, created: {created}, rate:{rate}, description:{description}')
796        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:
798    @staticmethod
799    def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
800        """
801        This function calculates the exchanged amount of a currency.
802
803        Args:
804            x (float): The original amount of the currency.
805            x_rate (float): The exchange rate of the original currency.
806            y_rate (float): The exchange rate of the target currency.
807
808        Returns:
809            float: The exchanged amount of the target currency.
810        """
811        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:
813    def exchanges(self) -> dict:
814        """
815        Retrieve the recorded exchange rates for all accounts.
816
817        Parameters:
818        None
819
820        Returns:
821        dict: A dictionary containing all recorded exchange rates.
822        The keys are account names or numbers, and the values are dictionaries containing the exchange rates.
823        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
824        """
825        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:
827    def accounts(self) -> dict:
828        """
829        Returns a dictionary containing account numbers as keys and their respective balances as values.
830
831        Parameters:
832        None
833
834        Returns:
835        dict: A dictionary where keys are account numbers and values are their respective balances.
836        """
837        result = {}
838        for i in self._vault['account']:
839            result[i] = self._vault['account'][i]['balance']
840        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:
842    def boxes(self, account) -> dict:
843        """
844        Retrieve the boxes (transactions) associated with a specific account.
845
846        Parameters:
847        account (str): The account number for which to retrieve the boxes.
848
849        Returns:
850        dict: A dictionary containing the boxes associated with the given account.
851        If the account does not exist, an empty dictionary is returned.
852        """
853        if self.account_exists(account):
854            return self._vault['account'][account]['box']
855        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:
857    def logs(self, account) -> dict:
858        """
859        Retrieve the logs (transactions) associated with a specific account.
860
861        Parameters:
862        account (str): The account number for which to retrieve the logs.
863
864        Returns:
865        dict: A dictionary containing the logs associated with the given account.
866        If the account does not exist, an empty dictionary is returned.
867        """
868        if self.account_exists(account):
869            return self._vault['account'][account]['log']
870        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:
872    def add_file(self, account: str, ref: int, path: str) -> int:
873        """
874        Adds a file reference to a specific transaction log entry in the vault.
875
876        Parameters:
877        account (str): The account number associated with the transaction log.
878        ref (int): The reference to the transaction log entry.
879        path (str): The path of the file to be added.
880
881        Returns:
882        int: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
883        """
884        if self.account_exists(account):
885            if ref in self._vault['account'][account]['log']:
886                file_ref = self.time()
887                self._vault['account'][account]['log'][ref]['file'][file_ref] = path
888                no_lock = self.nolock()
889                self.lock()
890                self._step(Action.ADD_FILE, account, ref=ref, file=file_ref)
891                if no_lock:
892                    self.free(self.lock())
893                return file_ref
894        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:
896    def remove_file(self, account: str, ref: int, file_ref: int) -> bool:
897        """
898        Removes a file reference from a specific transaction log entry in the vault.
899
900        Parameters:
901        account (str): The account number associated with the transaction log.
902        ref (int): The reference to the transaction log entry.
903        file_ref (int): The reference of the file to be removed.
904
905        Returns:
906        bool: True if the file reference is successfully removed, False otherwise.
907        """
908        if self.account_exists(account):
909            if ref in self._vault['account'][account]['log']:
910                if file_ref in self._vault['account'][account]['log'][ref]['file']:
911                    x = self._vault['account'][account]['log'][ref]['file'][file_ref]
912                    del self._vault['account'][account]['log'][ref]['file'][file_ref]
913                    no_lock = self.nolock()
914                    self.lock()
915                    self._step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x)
916                    if no_lock:
917                        self.free(self.lock())
918                    return True
919        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:
921    def balance(self, account: str = 1, cached: bool = True) -> int:
922        """
923        Calculate and return the balance of a specific account.
924
925        Parameters:
926        account (str): The account number. Default is '1'.
927        cached (bool): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
928
929        Returns:
930        int: The balance of the account.
931
932        Note:
933        If cached is True, the function returns the cached balance.
934        If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
935        """
936        if cached:
937            return self._vault['account'][account]['balance']
938        x = 0
939        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:
941    def hide(self, account, status: bool = None) -> bool:
942        """
943        Check or set the hide status of a specific account.
944
945        Parameters:
946        account (str): The account number.
947        status (bool, optional): The new hide status. If not provided, the function will return the current status.
948
949        Returns:
950        bool: The current or updated hide status of the account.
951
952        Raises:
953        None
954
955        Example:
956        >>> tracker = ZakatTracker()
957        >>> ref = tracker.track(51, 'desc', 'account1')
958        >>> tracker.hide('account1')  # Set the hide status of 'account1' to True
959        False
960        >>> tracker.hide('account1', True)  # Set the hide status of 'account1' to True
961        True
962        >>> tracker.hide('account1')  # Get the hide status of 'account1' by default
963        True
964        >>> tracker.hide('account1', False)
965        False
966        """
967        if self.account_exists(account):
968            if status is None:
969                return self._vault['account'][account]['hide']
970            self._vault['account'][account]['hide'] = status
971            return status
972        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:
 974    def zakatable(self, account, status: bool = None) -> bool:
 975        """
 976        Check or set the zakatable status of a specific account.
 977
 978        Parameters:
 979        account (str): The account number.
 980        status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
 981
 982        Returns:
 983        bool: The current or updated zakatable status of the account.
 984
 985        Raises:
 986        None
 987
 988        Example:
 989        >>> tracker = ZakatTracker()
 990        >>> ref = tracker.track(51, 'desc', 'account1')
 991        >>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
 992        True
 993        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
 994        True
 995        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
 996        True
 997        >>> tracker.zakatable('account1', False)
 998        False
 999        """
1000        if self.account_exists(account):
1001            if status is None:
1002                return self._vault['account'][account]['zakatable']
1003            self._vault['account'][account]['zakatable'] = status
1004            return status
1005        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:
1007    def sub(self, x: float, desc: str = '', account: str = 1, created: int = None, debug: bool = False) -> tuple:
1008        """
1009        Subtracts a specified value from an account's balance.
1010
1011        Parameters:
1012        x (float): The amount to be subtracted.
1013        desc (str): A description for the transaction. Defaults to an empty string.
1014        account (str): The account from which the value will be subtracted. Defaults to '1'.
1015        created (int): The timestamp of the transaction. If not provided, the current timestamp will be used.
1016        debug (bool): A flag indicating whether to print debug information. Defaults to False.
1017
1018        Returns:
1019        tuple: A tuple containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
1020
1021        If the amount to subtract is greater than the account's balance,
1022        the remaining amount will be transferred to a new transaction with a negative value.
1023
1024        Raises:
1025        ValueError: The box transaction happened again in the same nanosecond time.
1026        ValueError: The log transaction happened again in the same nanosecond time.
1027        """
1028        if x < 0:
1029            return tuple()
1030        if x == 0:
1031            ref = self.track(x, '', account)
1032            return ref, ref
1033        if created is None:
1034            created = self.time()
1035        no_lock = self.nolock()
1036        self.lock()
1037        self.track(0, '', account)
1038        self._log(-x, desc, account, created)
1039        ids = sorted(self._vault['account'][account]['box'].keys())
1040        limit = len(ids) + 1
1041        target = x
1042        if debug:
1043            print('ids', ids)
1044        ages = []
1045        for i in range(-1, -limit, -1):
1046            if target == 0:
1047                break
1048            j = ids[i]
1049            if debug:
1050                print('i', i, 'j', j)
1051            rest = self._vault['account'][account]['box'][j]['rest']
1052            if rest >= target:
1053                self._vault['account'][account]['box'][j]['rest'] -= target
1054                self._step(Action.SUB, account, ref=j, value=target)
1055                ages.append((j, target))
1056                target = 0
1057                break
1058            elif target > rest > 0:
1059                chunk = rest
1060                target -= chunk
1061                self._step(Action.SUB, account, ref=j, value=chunk)
1062                ages.append((j, chunk))
1063                self._vault['account'][account]['box'][j]['rest'] = 0
1064        if target > 0:
1065            self.track(-target, desc, account, False, created)
1066            ages.append((created, target))
1067        if no_lock:
1068            self.free(self.lock())
1069        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]:
1071    def transfer(self, amount: int, from_account: str, to_account: str, desc: str = '', created: int = None,
1072                 debug: bool = False) -> list[int]:
1073        """
1074        Transfers a specified value from one account to another.
1075
1076        Parameters:
1077        amount (int): The amount to be transferred.
1078        from_account (str): The account from which the value will be transferred.
1079        to_account (str): The account to which the value will be transferred.
1080        desc (str, optional): A description for the transaction. Defaults to an empty string.
1081        created (int, optional): The timestamp of the transaction. If not provided, the current timestamp will be used.
1082        debug (bool): A flag indicating whether to print debug information. Defaults to False.
1083
1084        Returns:
1085        list[int]: A list of timestamps corresponding to the transactions made during the transfer.
1086
1087        Raises:
1088        ValueError: Transfer to the same account is forbidden.
1089        ValueError: The box transaction happened again in the same nanosecond time.
1090        ValueError: The log transaction happened again in the same nanosecond time.
1091        """
1092        if from_account == to_account:
1093            raise ValueError(f'Transfer to the same account is forbidden. {to_account}')
1094        if amount <= 0:
1095            return []
1096        if created is None:
1097            created = self.time()
1098        (_, ages) = self.sub(amount, desc, from_account, created, debug=debug)
1099        times = []
1100        source_exchange = self.exchange(from_account, created)
1101        target_exchange = self.exchange(to_account, created)
1102
1103        if debug:
1104            print('ages', ages)
1105
1106        for age, value in ages:
1107            target_amount = self.exchange_calc(value, source_exchange['rate'], target_exchange['rate'])
1108            # Perform the transfer
1109            if self.box_exists(to_account, age):
1110                if debug:
1111                    print('box_exists', age)
1112                capital = self._vault['account'][to_account]['box'][age]['capital']
1113                rest = self._vault['account'][to_account]['box'][age]['rest']
1114                if debug:
1115                    print(
1116                        f"Transfer {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1117                selected_age = age
1118                if rest + target_amount > capital:
1119                    self._vault['account'][to_account]['box'][age]['capital'] += target_amount
1120                    selected_age = ZakatTracker.time()
1121                self._vault['account'][to_account]['box'][age]['rest'] += target_amount
1122                self._step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount)
1123                y = self._log(target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account,
1124                              debug=debug)
1125                times.append((age, y))
1126                continue
1127            y = self.track(target_amount, desc, to_account, logging=True, created=age, debug=debug)
1128            if debug:
1129                print(
1130                    f"Transferred {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1131            times.append(y)
1132        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:
1134    def check(self, silver_gram_price: float, nisab: float = None, debug: bool = False, now: int = None,
1135              cycle: float = None) -> tuple:
1136        """
1137        Check the eligibility for Zakat based on the given parameters.
1138
1139        Parameters:
1140        silver_gram_price (float): The price of a gram of silver.
1141        nisab (float): The minimum amount of wealth required for Zakat. If not provided,
1142                        it will be calculated based on the silver_gram_price.
1143        debug (bool): Flag to enable debug mode.
1144        now (int): The current timestamp. If not provided, it will be calculated using ZakatTracker.time().
1145        cycle (float): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
1146
1147        Returns:
1148        tuple: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics,
1149        and a dictionary containing the Zakat plan.
1150        """
1151        if now is None:
1152            now = self.time()
1153        if cycle is None:
1154            cycle = ZakatTracker.TimeCycle()
1155        if nisab is None:
1156            nisab = ZakatTracker.Nisab(silver_gram_price)
1157        plan = {}
1158        below_nisab = 0
1159        brief = [0, 0, 0]
1160        valid = False
1161        for x in self._vault['account']:
1162            if not self.zakatable(x):
1163                continue
1164            _box = self._vault['account'][x]['box']
1165            _log = self._vault['account'][x]['log']
1166            limit = len(_box) + 1
1167            ids = sorted(self._vault['account'][x]['box'].keys())
1168            for i in range(-1, -limit, -1):
1169                j = ids[i]
1170                rest = _box[j]['rest']
1171                if rest <= 0:
1172                    continue
1173                exchange = self.exchange(x, created=self.time())
1174                if debug:
1175                    print('exchanges', self.exchanges())
1176                rest = ZakatTracker.exchange_calc(rest, exchange['rate'], 1)
1177                brief[0] += rest
1178                index = limit + i - 1
1179                epoch = (now - j) / cycle
1180                if debug:
1181                    print(f"Epoch: {epoch}", _box[j])
1182                if _box[j]['last'] > 0:
1183                    epoch = (now - _box[j]['last']) / cycle
1184                if debug:
1185                    print(f"Epoch: {epoch}")
1186                epoch = floor(epoch)
1187                if debug:
1188                    print(f"Epoch: {epoch}", type(epoch), epoch == 0, 1 - epoch, epoch)
1189                if epoch == 0:
1190                    continue
1191                if debug:
1192                    print("Epoch - PASSED")
1193                brief[1] += rest
1194                if rest >= nisab:
1195                    total = 0
1196                    for _ in range(epoch):
1197                        total += ZakatTracker.ZakatCut(float(rest) - float(total))
1198                    if total > 0:
1199                        if x not in plan:
1200                            plan[x] = {}
1201                        valid = True
1202                        brief[2] += total
1203                        plan[x][index] = {
1204                            'total': total,
1205                            'count': epoch,
1206                            'box_time': j,
1207                            'box_capital': _box[j]['capital'],
1208                            'box_rest': _box[j]['rest'],
1209                            'box_last': _box[j]['last'],
1210                            'box_total': _box[j]['total'],
1211                            'box_count': _box[j]['count'],
1212                            'box_log': _log[j]['desc'],
1213                            'exchange_rate': exchange['rate'],
1214                            'exchange_time': exchange['time'],
1215                            'exchange_desc': exchange['description'],
1216                        }
1217                else:
1218                    chunk = ZakatTracker.ZakatCut(float(rest))
1219                    if chunk > 0:
1220                        if x not in plan:
1221                            plan[x] = {}
1222                        if j not in plan[x].keys():
1223                            plan[x][index] = {}
1224                        below_nisab += rest
1225                        brief[2] += chunk
1226                        plan[x][index]['below_nisab'] = chunk
1227                        plan[x][index]['total'] = chunk
1228                        plan[x][index]['count'] = epoch
1229                        plan[x][index]['box_time'] = j
1230                        plan[x][index]['box_capital'] = _box[j]['capital']
1231                        plan[x][index]['box_rest'] = _box[j]['rest']
1232                        plan[x][index]['box_last'] = _box[j]['last']
1233                        plan[x][index]['box_total'] = _box[j]['total']
1234                        plan[x][index]['box_count'] = _box[j]['count']
1235                        plan[x][index]['box_log'] = _log[j]['desc']
1236                        plan[x][index]['exchange_rate'] = exchange['rate']
1237                        plan[x][index]['exchange_time'] = exchange['time']
1238                        plan[x][index]['exchange_desc'] = exchange['description']
1239        valid = valid or below_nisab >= nisab
1240        if debug:
1241            print(f"below_nisab({below_nisab}) >= nisab({nisab})")
1242        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:
1244    def build_payment_parts(self, demand: float, positive_only: bool = True) -> dict:
1245        """
1246        Build payment parts for the Zakat distribution.
1247
1248        Parameters:
1249        demand (float): The total demand for payment in local currency.
1250        positive_only (bool): If True, only consider accounts with positive balance. Default is True.
1251
1252        Returns:
1253        dict: A dictionary containing the payment parts for each account. The dictionary has the following structure:
1254        {
1255            'account': {
1256                'account_id': {'balance': float, 'rate': float, 'part': float},
1257                ...
1258            },
1259            'exceed': bool,
1260            'demand': float,
1261            'total': float,
1262        }
1263        """
1264        total = 0
1265        parts = {
1266            'account': {},
1267            'exceed': False,
1268            'demand': demand,
1269        }
1270        for x, y in self.accounts().items():
1271            if positive_only and y <= 0:
1272                continue
1273            total += y
1274            exchange = self.exchange(x)
1275            parts['account'][x] = {'balance': y, 'rate': exchange['rate'], 'part': 0}
1276        parts['total'] = total
1277        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:
1279    @staticmethod
1280    def check_payment_parts(parts: dict, debug: bool = False) -> int:
1281        """
1282        Checks the validity of payment parts.
1283
1284        Parameters:
1285        parts (dict): A dictionary containing payment parts information.
1286        debug (bool): Flag to enable debug mode.
1287
1288        Returns:
1289        int: Returns 0 if the payment parts are valid, otherwise returns the error code.
1290
1291        Error Codes:
1292        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
1293        2: 'balance', 'rate' or 'part' key is missing in parts['account'][x].
1294        3: 'part' value in parts['account'][x] is less than 0.
1295        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
1296        5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
1297        6: The sum of 'part' values in parts['account'] does not match with 'demand' value.
1298        """
1299        for i in ['demand', 'account', 'total', 'exceed']:
1300            if i not in parts:
1301                return 1
1302        exceed = parts['exceed']
1303        for x in parts['account']:
1304            for j in ['balance', 'rate', 'part']:
1305                if j not in parts['account'][x]:
1306                    return 2
1307                if parts['account'][x]['part'] < 0:
1308                    return 3
1309                if not exceed and parts['account'][x]['balance'] <= 0:
1310                    return 4
1311        demand = parts['demand']
1312        z = 0
1313        for _, y in parts['account'].items():
1314            if not exceed and y['part'] > y['balance']:
1315                return 5
1316            z += ZakatTracker.exchange_calc(y['part'], y['rate'], 1)
1317        z = round(z, 2)
1318        demand = round(demand, 2)
1319        if debug:
1320            print('check_payment_parts', f'z = {z}, demand = {demand}')
1321            print('check_payment_parts', type(z), type(demand))
1322            print('check_payment_parts', z != demand)
1323            print('check_payment_parts', str(z) != str(demand))
1324        if z != demand and str(z) != str(demand):
1325            return 6
1326        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: List[Dict[str, Union[Dict, bool, Any]]] = None, debug: bool = False) -> bool:
1328    def zakat(self, report: tuple, parts: List[Dict[str, Dict | bool | Any]] = None, debug: bool = False) -> bool:
1329        """
1330        Perform Zakat calculation based on the given report and optional parts.
1331
1332        Parameters:
1333        report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan.
1334        parts (dict): A dictionary containing the payment parts for the zakat.
1335        debug (bool): A flag indicating whether to print debug information.
1336
1337        Returns:
1338        bool: True if the zakat calculation is successful, False otherwise.
1339        """
1340        valid, _, plan = report
1341        if not valid:
1342            return valid
1343        parts_exist = parts is not None
1344        if parts_exist:
1345            for part in parts:
1346                if self.check_payment_parts(part) != 0:
1347                    return False
1348        if debug:
1349            print('######### zakat #######')
1350            print('parts_exist', parts_exist)
1351        no_lock = self.nolock()
1352        self.lock()
1353        report_time = self.time()
1354        self._vault['report'][report_time] = report
1355        self._step(Action.REPORT, ref=report_time)
1356        created = self.time()
1357        for x in plan:
1358            target_exchange = self.exchange(x)
1359            if debug:
1360                print(plan[x])
1361                print('-------------')
1362                print(self._vault['account'][x]['box'])
1363            ids = sorted(self._vault['account'][x]['box'].keys())
1364            if debug:
1365                print('plan[x]', plan[x])
1366            for i in plan[x].keys():
1367                j = ids[i]
1368                if debug:
1369                    print('i', i, 'j', j)
1370                self._step(Action.ZAKAT, account=x, ref=j, value=self._vault['account'][x]['box'][j]['last'],
1371                           key='last',
1372                           math_operation=MathOperation.EQUAL)
1373                self._vault['account'][x]['box'][j]['last'] = created
1374                amount = ZakatTracker.exchange_calc(float(plan[x][i]['total']), 1, float(target_exchange['rate']))
1375                self._vault['account'][x]['box'][j]['total'] += amount
1376                self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='total',
1377                           math_operation=MathOperation.ADDITION)
1378                self._vault['account'][x]['box'][j]['count'] += plan[x][i]['count']
1379                self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['count'], key='count',
1380                           math_operation=MathOperation.ADDITION)
1381                if not parts_exist:
1382                    try:
1383                        self._vault['account'][x]['box'][j]['rest'] -= amount
1384                    except TypeError:
1385                        self._vault['account'][x]['box'][j]['rest'] -= Decimal(amount)
1386                    # self._step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest',
1387                    #            math_operation=MathOperation.SUBTRACTION)
1388                    self._log(-float(amount), desc='zakat', account=x, created=None, debug=debug)
1389        if parts_exist:
1390            for transaction in parts:
1391                for account, part in transaction['account'].items():
1392                    if debug:
1393                        print('zakat-part', account, part['rate'])
1394                    target_exchange = self.exchange(account)
1395                    amount = ZakatTracker.exchange_calc(part['part'], part['rate'], target_exchange['rate'])
1396                    self.sub(amount, desc='zakat-part', account=account, debug=debug)
1397        if no_lock:
1398            self.free(self.lock())
1399        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:
1401    def export_json(self, path: str = "data.json") -> bool:
1402        """
1403        Exports the current state of the ZakatTracker object to a JSON file.
1404
1405        Parameters:
1406        path (str): The path where the JSON file will be saved. Default is "data.json".
1407
1408        Returns:
1409        bool: True if the export is successful, False otherwise.
1410
1411        Raises:
1412        No specific exceptions are raised by this method.
1413        """
1414        with open(path, "w") as file:
1415            json.dump(self._vault, file, indent=4, cls=JSONEncoder)
1416            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:
1418    def save(self, path: str = None) -> bool:
1419        """
1420        Saves the ZakatTracker's current state to a pickle file.
1421
1422        This method serializes the internal data (`_vault`) along with metadata
1423        (Python version, pickle protocol) for future compatibility.
1424
1425        Parameters:
1426        path (str, optional): File path for saving. Defaults to a predefined location.
1427
1428        Returns:
1429        bool: True if the save operation is successful, False otherwise.
1430        """
1431        if path is None:
1432            path = self.path()
1433        with open(path, "wb") as f:
1434            version = f'{version_info.major}.{version_info.minor}.{version_info.micro}'
1435            pickle_protocol = pickle.HIGHEST_PROTOCOL
1436            data = {
1437                'python_version': version,
1438                'pickle_protocol': pickle_protocol,
1439                'data': self._vault,
1440            }
1441            pickle.dump(data, f, protocol=pickle_protocol)
1442            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:
1444    def load(self, path: str = None) -> bool:
1445        """
1446        Load the current state of the ZakatTracker object from a pickle file.
1447
1448        Parameters:
1449        path (str): The path where the pickle file is located. If not provided, it will use the default path.
1450
1451        Returns:
1452        bool: True if the load operation is successful, False otherwise.
1453        """
1454        if path is None:
1455            path = self.path()
1456        if os.path.exists(path):
1457            with open(path, "rb") as f:
1458                data = pickle.load(f)
1459                self._vault = data['data']
1460                return True
1461        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):
1463    def import_csv_cache_path(self):
1464        """
1465        Generates the cache file path for imported CSV data.
1466
1467        This function constructs the file path where cached data from CSV imports
1468        will be stored. The cache file is a pickle file (.pickle extension) appended
1469        to the base path of the object.
1470
1471        Returns:
1472        str: The full path to the import CSV cache file.
1473
1474        Example:
1475            >>> obj = ZakatTracker('/data/reports')
1476            >>> obj.import_csv_cache_path()
1477            '/data/reports.import_csv.pickle'
1478        """
1479        path = self.path()
1480        if path.endswith(".pickle"):
1481            path = path[:-7]
1482        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:
1484    def import_csv(self, path: str = 'file.csv', debug: bool = False) -> tuple:
1485        """
1486        The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system.
1487
1488        Parameters:
1489        path (str): The path to the CSV file. Default is 'file.csv'.
1490        debug (bool): A flag indicating whether to print debug information.
1491
1492        Returns:
1493        tuple: A tuple containing the number of transactions created, the number of transactions found in the cache,
1494                and a dictionary of bad transactions.
1495
1496        Notes:
1497            * Currency Pair Assumption: This function assumes that the exchange rates stored for each account
1498                                        are appropriate for the currency pairs involved in the conversions.
1499            * The exchange rate for each account is based on the last encountered transaction rate that is not equal
1500                to 1.0 or the previous rate for that account.
1501            * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent
1502              transactions of the same account within the whole imported and existing dataset when doing `check` and
1503              `zakat` operations.
1504
1505        Example Usage:
1506            The CSV file should have the following format, rate is optional per transaction:
1507            account, desc, value, date, rate
1508            For example:
1509            safe-45, "Some text", 34872, 1988-06-30 00:00:00, 1
1510        """
1511        cache: list[int] = []
1512        try:
1513            with open(self.import_csv_cache_path(), "rb") as f:
1514                cache = pickle.load(f)
1515        except:
1516            pass
1517        date_formats = [
1518            "%Y-%m-%d %H:%M:%S",
1519            "%Y-%m-%dT%H:%M:%S",
1520            "%Y-%m-%dT%H%M%S",
1521            "%Y-%m-%d",
1522        ]
1523        created, found, bad = 0, 0, {}
1524        data: list[tuple] = []
1525        with open(path, newline='', encoding="utf-8") as f:
1526            i = 0
1527            for row in csv.reader(f, delimiter=','):
1528                i += 1
1529                hashed = hash(tuple(row))
1530                if hashed in cache:
1531                    found += 1
1532                    continue
1533                account = row[0]
1534                desc = row[1]
1535                value = float(row[2])
1536                rate = 1.0
1537                if row[4:5]:  # Empty list if index is out of range
1538                    rate = float(row[4])
1539                date: int = 0
1540                for time_format in date_formats:
1541                    try:
1542                        date = self.time(datetime.datetime.strptime(row[3], time_format))
1543                        break
1544                    except:
1545                        pass
1546                # TODO: not allowed for negative dates
1547                if date == 0 or value == 0:
1548                    bad[i] = row
1549                    continue
1550                if date in data:
1551                    print('import_csv-duplicated(time)', date)
1552                    continue
1553                data.append((date, value, desc, account, rate, hashed))
1554
1555        if debug:
1556            print('import_csv', len(data))
1557        for row in sorted(data, key=lambda x: x[0]):
1558            (date, value, desc, account, rate, hashed) = row
1559            if rate > 1:
1560                self.exchange(account, created=date, rate=rate)
1561            if value > 0:
1562                self.track(value, desc, account, True, date)
1563            elif value < 0:
1564                self.sub(-value, desc, account, date)
1565            created += 1
1566            cache.append(hashed)
1567        with open(self.import_csv_cache_path(), "wb") as f:
1568            pickle.dump(cache, f)
1569        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 duration_from_nanoseconds(ns: int) -> tuple:
1575    @staticmethod
1576    def duration_from_nanoseconds(ns: int) -> tuple:
1577        """
1578        REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
1579        Convert NanoSeconds to Human Readable Time Format.
1580        A NanoSeconds is a unit of time in the International System of Units (SI) equal
1581        to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
1582        Its symbol is μs, sometimes simplified to us when Unicode is not available.
1583        A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
1584
1585        INPUT : ms (AKA: MilliSeconds)
1586        OUTPUT: tuple(string time_lapsed, string spoken_time) like format.
1587        OUTPUT Variables: time_lapsed, spoken_time
1588
1589        Example  Input: duration_from_nanoseconds(ns)
1590        **"Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds"**
1591        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')
1592        duration_from_nanoseconds(1234567890123456789012)
1593        """
1594        us, ns = divmod(ns, 1000)
1595        ms, us = divmod(us, 1000)
1596        s, ms = divmod(ms, 1000)
1597        m, s = divmod(s, 60)
1598        h, m = divmod(m, 60)
1599        d, h = divmod(h, 24)
1600        y, d = divmod(d, 365)
1601        c, y = divmod(y, 100)
1602        n, c = divmod(c, 10)
1603        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}"
1604        spoken_time = f"{n: 3d} Millennia, {c: 4d} Century, {y: 3d} Years, {d: 4d} Days, {h: 2d} Hours, {m: 2d} Minutes, {s: 2d} Seconds, {ms: 3d} MilliSeconds, {us: 3d} MicroSeconds, {ns: 3d} NanoSeconds"
1605        return time_lapsed, spoken_time

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:
1607    @staticmethod
1608    def day_to_time(day: int, month: int = 6, year: int = 2024) -> int:  # افتراض أن الشهر هو يونيو والسنة 2024
1609        """
1610        Convert a specific day, month, and year into a timestamp.
1611
1612        Parameters:
1613        day (int): The day of the month.
1614        month (int): The month of the year. Default is 6 (June).
1615        year (int): The year. Default is 2024.
1616
1617        Returns:
1618        int: The timestamp representing the given day, month, and year.
1619
1620        Note:
1621        This method assumes the default month and year if not provided.
1622        """
1623        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:
1625    @staticmethod
1626    def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
1627        """
1628        Generate a random date between two given dates.
1629
1630        Parameters:
1631        start_date (datetime.datetime): The start date from which to generate a random date.
1632        end_date (datetime.datetime): The end date until which to generate a random date.
1633
1634        Returns:
1635        datetime.datetime: A random date between the start_date and end_date.
1636        """
1637        time_between_dates = end_date - start_date
1638        days_between_dates = time_between_dates.days
1639        random_number_of_days = random.randrange(days_between_dates)
1640        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:
1642    @staticmethod
1643    def generate_random_csv_file(path: str = "data.csv", count: int = 1000, with_rate: bool = False,
1644                                 debug: bool = False) -> int:
1645        """
1646        Generate a random CSV file with specified parameters.
1647
1648        Parameters:
1649        path (str): The path where the CSV file will be saved. Default is "data.csv".
1650        count (int): The number of rows to generate in the CSV file. Default is 1000.
1651        with_rate (bool): If True, a random rate between 1.2% and 12% is added. Default is False.
1652        debug (bool): A flag indicating whether to print debug information.
1653
1654        Returns:
1655        None. The function generates a CSV file at the specified path with the given count of rows.
1656        Each row contains a randomly generated account, description, value, and date.
1657        The value is randomly generated between 1000 and 100000,
1658        and the date is randomly generated between 1950-01-01 and 2023-12-31.
1659        If the row number is not divisible by 13, the value is multiplied by -1.
1660        """
1661        i = 0
1662        with open(path, "w", newline="") as csvfile:
1663            writer = csv.writer(csvfile)
1664            for i in range(count):
1665                account = f"acc-{random.randint(1, 1000)}"
1666                desc = f"Some text {random.randint(1, 1000)}"
1667                value = random.randint(1000, 100000)
1668                date = ZakatTracker.generate_random_date(datetime.datetime(1950, 1, 1),
1669                                                         datetime.datetime(2023, 12, 31)).strftime("%Y-%m-%d %H:%M:%S")
1670                if not i % 13 == 0:
1671                    value *= -1
1672                row = [account, desc, value, date]
1673                if with_rate:
1674                    rate = random.randint(1, 100) * 0.12
1675                    if debug:
1676                        print('before-append', row)
1677                    row.append(rate)
1678                    if debug:
1679                        print('after-append', row)
1680                writer.writerow(row)
1681                i = i + 1
1682        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):
1684    @staticmethod
1685    def create_random_list(max_sum, min_value=0, max_value=10):
1686        """
1687        Creates a list of random integers whose sum does not exceed the specified maximum.
1688
1689        Args:
1690            max_sum: The maximum allowed sum of the list elements.
1691            min_value: The minimum possible value for an element (inclusive).
1692            max_value: The maximum possible value for an element (inclusive).
1693
1694        Returns:
1695            A list of random integers.
1696        """
1697        result = []
1698        current_sum = 0
1699
1700        while current_sum < max_sum:
1701            # Calculate the remaining space for the next element
1702            remaining_sum = max_sum - current_sum
1703            # Determine the maximum possible value for the next element
1704            next_max_value = min(remaining_sum, max_value)
1705            # Generate a random element within the allowed range
1706            next_element = random.randint(min_value, next_max_value)
1707            result.append(next_element)
1708            current_sum += next_element
1709
1710        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:
1855    def test(self, debug: bool = False) -> bool:
1856
1857        try:
1858
1859            assert self._history()
1860
1861            # Not allowed for duplicate transactions in the same account and time
1862
1863            created = ZakatTracker.time()
1864            self.track(100, 'test-1', 'same', True, created)
1865            failed = False
1866            try:
1867                self.track(50, 'test-1', 'same', True, created)
1868            except:
1869                failed = True
1870            assert failed is True
1871
1872            self.reset()
1873
1874            # Same account transfer
1875            for x in [1, 'a', True, 1.8, None]:
1876                failed = False
1877                try:
1878                    self.transfer(1, x, x, 'same-account', debug=debug)
1879                except:
1880                    failed = True
1881                assert failed is True
1882
1883            # Always preserve box age during transfer
1884
1885            series: list[tuple] = [
1886                (30, 4),
1887                (60, 3),
1888                (90, 2),
1889            ]
1890            case = {
1891                30: {
1892                    'series': series,
1893                    'rest': 150,
1894                },
1895                60: {
1896                    'series': series,
1897                    'rest': 120,
1898                },
1899                90: {
1900                    'series': series,
1901                    'rest': 90,
1902                },
1903                180: {
1904                    'series': series,
1905                    'rest': 0,
1906                },
1907                270: {
1908                    'series': series,
1909                    'rest': -90,
1910                },
1911                360: {
1912                    'series': series,
1913                    'rest': -180,
1914                },
1915            }
1916
1917            selected_time = ZakatTracker.time() - ZakatTracker.TimeCycle()
1918
1919            for total in case:
1920                for x in case[total]['series']:
1921                    self.track(x[0], f"test-{x} ages", 'ages', True, selected_time * x[1])
1922
1923                refs = self.transfer(total, 'ages', 'future', 'Zakat Movement', debug=debug)
1924
1925                if debug:
1926                    print('refs', refs)
1927
1928                ages_cache_balance = self.balance('ages')
1929                ages_fresh_balance = self.balance('ages', False)
1930                rest = case[total]['rest']
1931                if debug:
1932                    print('source', ages_cache_balance, ages_fresh_balance, rest)
1933                assert ages_cache_balance == rest
1934                assert ages_fresh_balance == rest
1935
1936                future_cache_balance = self.balance('future')
1937                future_fresh_balance = self.balance('future', False)
1938                if debug:
1939                    print('target', future_cache_balance, future_fresh_balance, total)
1940                    print('refs', refs)
1941                assert future_cache_balance == total
1942                assert future_fresh_balance == total
1943
1944                for ref in self._vault['account']['ages']['box']:
1945                    ages_capital = self._vault['account']['ages']['box'][ref]['capital']
1946                    ages_rest = self._vault['account']['ages']['box'][ref]['rest']
1947                    future_capital = 0
1948                    if ref in self._vault['account']['future']['box']:
1949                        future_capital = self._vault['account']['future']['box'][ref]['capital']
1950                    future_rest = 0
1951                    if ref in self._vault['account']['future']['box']:
1952                        future_rest = self._vault['account']['future']['box'][ref]['rest']
1953                    if ages_capital != 0 and future_capital != 0 and future_rest != 0:
1954                        if debug:
1955                            print('================================================================')
1956                            print('ages', ages_capital, ages_rest)
1957                            print('future', future_capital, future_rest)
1958                        if ages_rest == 0:
1959                            assert ages_capital == future_capital
1960                        elif ages_rest < 0:
1961                            assert -ages_capital == future_capital
1962                        elif ages_rest > 0:
1963                            assert ages_capital == ages_rest + future_capital
1964                self.reset()
1965                assert len(self._vault['history']) == 0
1966
1967            assert self._history()
1968            assert self._history(False) is False
1969            assert self._history() is False
1970            assert self._history(True)
1971            assert self._history()
1972
1973            self._test_core(True, debug)
1974            self._test_core(False, debug)
1975
1976            transaction = [
1977                (
1978                    20, 'wallet', 1, 800, 800, 800, 4, 5,
1979                    -85, -85, -85, 6, 7,
1980                ),
1981                (
1982                    750, 'wallet', 'safe', 50, 50, 50, 4, 6,
1983                    750, 750, 750, 1, 1,
1984                ),
1985                (
1986                    600, 'safe', 'bank', 150, 150, 150, 1, 2,
1987                    600, 600, 600, 1, 1,
1988                ),
1989            ]
1990            for z in transaction:
1991                self.lock()
1992                x = z[1]
1993                y = z[2]
1994                self.transfer(z[0], x, y, 'test-transfer', debug=debug)
1995                assert self.balance(x) == z[3]
1996                xx = self.accounts()[x]
1997                assert xx == z[3]
1998                assert self.balance(x, False) == z[4]
1999                assert xx == z[4]
2000
2001                s = 0
2002                log = self._vault['account'][x]['log']
2003                for i in log:
2004                    s += log[i]['value']
2005                if debug:
2006                    print('s', s, 'z[5]', z[5])
2007                assert s == z[5]
2008
2009                assert self.box_size(x) == z[6]
2010                assert self.log_size(x) == z[7]
2011
2012                yy = self.accounts()[y]
2013                assert self.balance(y) == z[8]
2014                assert yy == z[8]
2015                assert self.balance(y, False) == z[9]
2016                assert yy == z[9]
2017
2018                s = 0
2019                log = self._vault['account'][y]['log']
2020                for i in log:
2021                    s += log[i]['value']
2022                assert s == z[10]
2023
2024                assert self.box_size(y) == z[11]
2025                assert self.log_size(y) == z[12]
2026
2027            if debug:
2028                pp().pprint(self.check(2.17))
2029
2030            assert not self.nolock()
2031            history_count = len(self._vault['history'])
2032            if debug:
2033                print('history-count', history_count)
2034            assert history_count == 11
2035            assert not self.free(ZakatTracker.time())
2036            assert self.free(self.lock())
2037            assert self.nolock()
2038            assert len(self._vault['history']) == 11
2039
2040            # storage
2041
2042            _path = self.path('test.pickle')
2043            if os.path.exists(_path):
2044                os.remove(_path)
2045            self.save()
2046            assert os.path.getsize(_path) > 0
2047            self.reset()
2048            assert self.recall(False, debug) is False
2049            self.load()
2050            assert self._vault['account'] is not None
2051
2052            # recall
2053
2054            assert self.nolock()
2055            assert len(self._vault['history']) == 11
2056            assert self.recall(False, debug) is True
2057            assert len(self._vault['history']) == 10
2058            assert self.recall(False, debug) is True
2059            assert len(self._vault['history']) == 9
2060
2061            csv_count = 1000
2062
2063            for with_rate, path in {
2064                False: 'test-import_csv-no-exchange',
2065                True: 'test-import_csv-with-exchange',
2066            }.items():
2067
2068                if debug:
2069                    print('test_import_csv', with_rate, path)
2070
2071                # csv
2072
2073                csv_path = path + '.csv'
2074                if os.path.exists(csv_path):
2075                    os.remove(csv_path)
2076                c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug)
2077                if debug:
2078                    print('generate_random_csv_file', c)
2079                assert c == csv_count
2080                assert os.path.getsize(csv_path) > 0
2081                cache_path = self.import_csv_cache_path()
2082                if os.path.exists(cache_path):
2083                    os.remove(cache_path)
2084                self.reset()
2085                (created, found, bad) = self.import_csv(csv_path, debug)
2086                bad_count = len(bad)
2087                if debug:
2088                    print(f"csv-imported: ({created}, {found}, {bad_count}) = count({csv_count})")
2089                tmp_size = os.path.getsize(cache_path)
2090                assert tmp_size > 0
2091                assert created + found + bad_count == csv_count
2092                assert created == csv_count
2093                assert bad_count == 0
2094                (created_2, found_2, bad_2) = self.import_csv(csv_path)
2095                bad_2_count = len(bad_2)
2096                if debug:
2097                    print(f"csv-imported: ({created_2}, {found_2}, {bad_2_count})")
2098                    print(bad)
2099                assert tmp_size == os.path.getsize(cache_path)
2100                assert created_2 + found_2 + bad_2_count == csv_count
2101                assert created == found_2
2102                assert bad_count == bad_2_count
2103                assert found_2 == csv_count
2104                assert bad_2_count == 0
2105                assert created_2 == 0
2106
2107                # payment parts
2108
2109                positive_parts = self.build_payment_parts(100, positive_only=True)
2110                assert self.check_payment_parts(positive_parts) != 0
2111                assert self.check_payment_parts(positive_parts) != 0
2112                all_parts = self.build_payment_parts(300, positive_only=False)
2113                assert self.check_payment_parts(all_parts) != 0
2114                assert self.check_payment_parts(all_parts) != 0
2115                if debug:
2116                    pp().pprint(positive_parts)
2117                    pp().pprint(all_parts)
2118                # dynamic discount
2119                suite = []
2120                count = 3
2121                for exceed in [False, True]:
2122                    case = []
2123                    for parts in [positive_parts, all_parts]:
2124                        part = parts.copy()
2125                        demand = part['demand']
2126                        if debug:
2127                            print(demand, part['total'])
2128                        i = 0
2129                        z = demand / count
2130                        cp = {
2131                            'account': {},
2132                            'demand': demand,
2133                            'exceed': exceed,
2134                            'total': part['total'],
2135                        }
2136                        j = ''
2137                        for x, y in part['account'].items():
2138                            x_exchange = self.exchange(x)
2139                            zz = self.exchange_calc(z, 1, x_exchange['rate'])
2140                            if exceed and zz <= demand:
2141                                i += 1
2142                                y['part'] = zz
2143                                if debug:
2144                                    print(exceed, y)
2145                                cp['account'][x] = y
2146                                case.append(y)
2147                            elif not exceed and y['balance'] >= zz:
2148                                i += 1
2149                                y['part'] = zz
2150                                if debug:
2151                                    print(exceed, y)
2152                                cp['account'][x] = y
2153                                case.append(y)
2154                            j = x
2155                            if i >= count:
2156                                break
2157                        if len(cp['account'][j]) > 0:
2158                            suite.append(cp)
2159                if debug:
2160                    print('suite', len(suite))
2161                for case in suite:
2162                    if debug:
2163                        print(case)
2164                    result = self.check_payment_parts(case)
2165                    if debug:
2166                        print('check_payment_parts', result, f'exceed: {exceed}')
2167                    assert result == 0
2168
2169                report = self.check(2.17, None, debug)
2170                (valid, brief, plan) = report
2171                if debug:
2172                    print('valid', valid)
2173                assert self.zakat(report, parts=suite, debug=debug)
2174                assert self.save(path + '.pickle')
2175                assert self.export_json(path + '.json')
2176
2177            # exchange
2178
2179            self.exchange("cash", 25, 3.75, "2024-06-25")
2180            self.exchange("cash", 22, 3.73, "2024-06-22")
2181            self.exchange("cash", 15, 3.69, "2024-06-15")
2182            self.exchange("cash", 10, 3.66)
2183
2184            for i in range(1, 30):
2185                exchange = self.exchange("cash", i)
2186                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2187                if debug:
2188                    print(i, rate, description, created)
2189                assert created
2190                if i < 10:
2191                    assert rate == 1
2192                    assert description is None
2193                elif i == 10:
2194                    assert rate == 3.66
2195                    assert description is None
2196                elif i < 15:
2197                    assert rate == 3.66
2198                    assert description is None
2199                elif i == 15:
2200                    assert rate == 3.69
2201                    assert description is not None
2202                elif i < 22:
2203                    assert rate == 3.69
2204                    assert description is not None
2205                elif i == 22:
2206                    assert rate == 3.73
2207                    assert description is not None
2208                elif i >= 25:
2209                    assert rate == 3.75
2210                    assert description is not None
2211                exchange = self.exchange("bank", i)
2212                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2213                if debug:
2214                    print(i, rate, description, created)
2215                assert created
2216                assert rate == 1
2217                assert description is None
2218
2219            assert len(self._vault['exchange']) > 0
2220            assert len(self.exchanges()) > 0
2221            self._vault['exchange'].clear()
2222            assert len(self._vault['exchange']) == 0
2223            assert len(self.exchanges()) == 0
2224
2225            # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية
2226            self.exchange("cash", ZakatTracker.day_to_time(25), 3.75, "2024-06-25")
2227            self.exchange("cash", ZakatTracker.day_to_time(22), 3.73, "2024-06-22")
2228            self.exchange("cash", ZakatTracker.day_to_time(15), 3.69, "2024-06-15")
2229            self.exchange("cash", ZakatTracker.day_to_time(10), 3.66)
2230
2231            for i in [x * 0.12 for x in range(-15, 21)]:
2232                if i <= 0:
2233                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) == 0
2234                else:
2235                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) > 0
2236
2237            # اختبار النتائج باستخدام التواريخ بالنانو ثانية
2238            for i in range(1, 31):
2239                timestamp_ns = ZakatTracker.day_to_time(i)
2240                exchange = self.exchange("cash", timestamp_ns)
2241                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2242                if debug:
2243                    print(i, rate, description, created)
2244                assert created
2245                if i < 10:
2246                    assert rate == 1
2247                    assert description is None
2248                elif i == 10:
2249                    assert rate == 3.66
2250                    assert description is None
2251                elif i < 15:
2252                    assert rate == 3.66
2253                    assert description is None
2254                elif i == 15:
2255                    assert rate == 3.69
2256                    assert description is not None
2257                elif i < 22:
2258                    assert rate == 3.69
2259                    assert description is not None
2260                elif i == 22:
2261                    assert rate == 3.73
2262                    assert description is not None
2263                elif i >= 25:
2264                    assert rate == 3.75
2265                    assert description is not None
2266                exchange = self.exchange("bank", i)
2267                rate, description, created = exchange['rate'], exchange['description'], exchange['time']
2268                if debug:
2269                    print(i, rate, description, created)
2270                assert created
2271                assert rate == 1
2272                assert description is None
2273
2274            assert self.export_json("1000-transactions-test.json")
2275            assert self.save("1000-transactions-test.pickle")
2276
2277            self.reset()
2278
2279            # test transfer between accounts with different exchange rate
2280
2281            a_SAR = "Bank (SAR)"
2282            b_USD = "Bank (USD)"
2283            c_SAR = "Safe (SAR)"
2284            # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer
2285            for case in [
2286                (0, a_SAR, "SAR Gift", 1000, 1000),
2287                (1, a_SAR, 1),
2288                (0, b_USD, "USD Gift", 500, 500),
2289                (1, b_USD, 1),
2290                (2, b_USD, 3.75),
2291                (1, b_USD, 3.75),
2292                (3, 100, b_USD, a_SAR, "100 USD -> SAR", 400, 1375),
2293                (0, c_SAR, "Salary", 750, 750),
2294                (3, 375, c_SAR, b_USD, "375 SAR -> USD", 375, 500),
2295                (3, 3.75, a_SAR, b_USD, "3.75 SAR -> USD", 1371.25, 501),
2296            ]:
2297                match (case[0]):
2298                    case 0:  # track
2299                        _, account, desc, x, balance = case
2300                        self.track(value=x, desc=desc, account=account, debug=debug)
2301
2302                        cached_value = self.balance(account, cached=True)
2303                        fresh_value = self.balance(account, cached=False)
2304                        if debug:
2305                            print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value)
2306                        assert cached_value == balance
2307                        assert fresh_value == balance
2308                    case 1:  # check-exchange
2309                        _, account, expected_rate = case
2310                        t_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
2311                        if debug:
2312                            print('t-exchange', t_exchange)
2313                        assert t_exchange['rate'] == expected_rate
2314                    case 2:  # do-exchange
2315                        _, account, rate = case
2316                        self.exchange(account, rate=rate, debug=debug)
2317                        b_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
2318                        if debug:
2319                            print('b-exchange', b_exchange)
2320                        assert b_exchange['rate'] == rate
2321                    case 3:  # transfer
2322                        _, x, a, b, desc, a_balance, b_balance = case
2323                        self.transfer(x, a, b, desc, debug=debug)
2324
2325                        cached_value = self.balance(a, cached=True)
2326                        fresh_value = self.balance(a, cached=False)
2327                        if debug:
2328                            print('account', a, 'cached_value', cached_value, 'fresh_value', fresh_value)
2329                        assert cached_value == a_balance
2330                        assert fresh_value == a_balance
2331
2332                        cached_value = self.balance(b, cached=True)
2333                        fresh_value = self.balance(b, cached=False)
2334                        if debug:
2335                            print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value)
2336                        assert cached_value == b_balance
2337                        assert fresh_value == b_balance
2338
2339            # Transfer all in many chunks randomly from B to A
2340            a_SAR_balance = 1371.25
2341            b_USD_balance = 501
2342            b_USD_exchange = self.exchange(b_USD)
2343            amounts = ZakatTracker.create_random_list(b_USD_balance)
2344            if debug:
2345                print('amounts', amounts)
2346            i = 0
2347            for x in amounts:
2348                if debug:
2349                    print(f'{i} - transfer-with-exchange({x})')
2350                self.transfer(x, b_USD, a_SAR, f"{x} USD -> SAR", debug=debug)
2351
2352                b_USD_balance -= x
2353                cached_value = self.balance(b_USD, cached=True)
2354                fresh_value = self.balance(b_USD, cached=False)
2355                if debug:
2356                    print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
2357                          b_USD_balance)
2358                assert cached_value == b_USD_balance
2359                assert fresh_value == b_USD_balance
2360
2361                a_SAR_balance += x * b_USD_exchange['rate']
2362                cached_value = self.balance(a_SAR, cached=True)
2363                fresh_value = self.balance(a_SAR, cached=False)
2364                if debug:
2365                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
2366                          a_SAR_balance, 'rate', b_USD_exchange['rate'])
2367                assert cached_value == a_SAR_balance
2368                assert fresh_value == a_SAR_balance
2369                i += 1
2370
2371            # Transfer all in many chunks randomly from C to A
2372            c_SAR_balance = 375
2373            amounts = ZakatTracker.create_random_list(c_SAR_balance)
2374            if debug:
2375                print('amounts', amounts)
2376            i = 0
2377            for x in amounts:
2378                if debug:
2379                    print(f'{i} - transfer-with-exchange({x})')
2380                self.transfer(x, c_SAR, a_SAR, f"{x} SAR -> a_SAR", debug=debug)
2381
2382                c_SAR_balance -= x
2383                cached_value = self.balance(c_SAR, cached=True)
2384                fresh_value = self.balance(c_SAR, cached=False)
2385                if debug:
2386                    print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
2387                          c_SAR_balance)
2388                assert cached_value == c_SAR_balance
2389                assert fresh_value == c_SAR_balance
2390
2391                a_SAR_balance += x
2392                cached_value = self.balance(a_SAR, cached=True)
2393                fresh_value = self.balance(a_SAR, cached=False)
2394                if debug:
2395                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
2396                          a_SAR_balance)
2397                assert cached_value == a_SAR_balance
2398                assert fresh_value == a_SAR_balance
2399                i += 1
2400
2401            assert self.export_json("accounts-transfer-with-exchange-rates.json")
2402            assert self.save("accounts-transfer-with-exchange-rates.pickle")
2403
2404            # check & zakat with exchange rates for many cycles
2405
2406            for rate, values in {
2407                1: {
2408                    'in': [1000, 2000, 10000],
2409                    'exchanged': [1000, 2000, 10000],
2410                    'out': [25, 50, 731.40625],
2411                },
2412                3.75: {
2413                    'in': [200, 1000, 5000],
2414                    'exchanged': [750, 3750, 18750],
2415                    'out': [18.75, 93.75, 1371.38671875],
2416                },
2417            }.items():
2418                a, b, c = values['in']
2419                m, n, o = values['exchanged']
2420                x, y, z = values['out']
2421                if debug:
2422                    print('rate', rate, 'values', values)
2423                for case in [
2424                    (a, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
2425                        {'safe': {0: {'below_nisab': x}}},
2426                    ], False, m),
2427                    (b, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
2428                        {'safe': {0: {'count': 1, 'total': y}}},
2429                    ], True, n),
2430                    (c, 'cave', ZakatTracker.time() - (ZakatTracker.TimeCycle() * 3), [
2431                        {'cave': {0: {'count': 3, 'total': z}}},
2432                    ], True, o),
2433                ]:
2434                    if debug:
2435                        print(f"############# check(rate: {rate}) #############")
2436                    self.reset()
2437                    self.exchange(account=case[1], created=case[2], rate=rate)
2438                    self.track(value=case[0], desc='test-check', account=case[1], logging=True, created=case[2])
2439
2440                    # assert self.nolock()
2441                    # history_size = len(self._vault['history'])
2442                    # print('history_size', history_size)
2443                    # assert history_size == 2
2444                    assert self.lock()
2445                    assert not self.nolock()
2446                    report = self.check(2.17, None, debug)
2447                    (valid, brief, plan) = report
2448                    assert valid == case[4]
2449                    if debug:
2450                        print('brief', brief)
2451                    assert case[5] == brief[0]
2452                    assert case[5] == brief[1]
2453
2454                    if debug:
2455                        pp().pprint(plan)
2456
2457                    for x in plan:
2458                        assert case[1] == x
2459                        if 'total' in case[3][0][x][0].keys():
2460                            assert case[3][0][x][0]['total'] == brief[2]
2461                            assert plan[x][0]['total'] == case[3][0][x][0]['total']
2462                            assert plan[x][0]['count'] == case[3][0][x][0]['count']
2463                        else:
2464                            assert plan[x][0]['below_nisab'] == case[3][0][x][0]['below_nisab']
2465                    if debug:
2466                        pp().pprint(report)
2467                    result = self.zakat(report, debug=debug)
2468                    if debug:
2469                        print('zakat-result', result, case[4])
2470                    assert result == case[4]
2471                    report = self.check(2.17, None, debug)
2472                    (valid, brief, plan) = report
2473                    assert valid is False
2474
2475            history_size = len(self._vault['history'])
2476            if debug:
2477                print('history_size', history_size)
2478            assert history_size == 3
2479            assert not self.nolock()
2480            assert self.recall(False, debug) is False
2481            self.free(self.lock())
2482            assert self.nolock()
2483            for i in range(3, 0, -1):
2484                history_size = len(self._vault['history'])
2485                if debug:
2486                    print('history_size', history_size)
2487                assert history_size == i
2488                assert self.recall(False, debug) is True
2489
2490            assert self.nolock()
2491
2492            assert self.recall(False, debug) is False
2493            history_size = len(self._vault['history'])
2494            if debug:
2495                print('history_size', history_size)
2496            assert history_size == 0
2497
2498            assert len(self._vault['account']) == 0
2499            assert len(self._vault['history']) == 0
2500            assert len(self._vault['report']) == 0
2501            assert self.nolock()
2502            return True
2503        except:
2504            # pp().pprint(self._vault)
2505            assert self.export_json("test-snapshot.json")
2506            assert self.save("test-snapshot.pickle")
2507            raise
class Action(enum.Enum):
72class Action(Enum):
73    CREATE = auto()
74    TRACK = auto()
75    LOG = auto()
76    SUB = auto()
77    ADD_FILE = auto()
78    REMOVE_FILE = auto()
79    BOX_TRANSFER = auto()
80    EXCHANGE = auto()
81    REPORT = auto()
82    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):
85class JSONEncoder(json.JSONEncoder):
86    def default(self, obj):
87        if isinstance(obj, Action) or isinstance(obj, MathOperation):
88            return obj.name  # Serialize as the enum member's name
89        elif isinstance(obj, Decimal):
90            return float(obj)
91        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):
86    def default(self, obj):
87        if isinstance(obj, Action) or isinstance(obj, MathOperation):
88            return obj.name  # Serialize as the enum member's name
89        elif isinstance(obj, Decimal):
90            return float(obj)
91        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):
94class MathOperation(Enum):
95    ADDITION = auto()
96    EQUAL = auto()
97    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'>