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

def ZakatCut(x):
162    ZakatCut = lambda x: 0.025 * x  # Zakat Cut in one Lunar Year
def TimeCycle(days=355):
163    TimeCycle = lambda days=355: int(60 * 60 * 24 * days * 1e9)  # Lunar Year in nanoseconds
def Nisab(x):
164    Nisab = lambda x: 595 * x  # Silver Price in Local currency value
def Version():
165    Version = lambda: '0.2.5'
def path(self, path: str = None) -> str:
185    def path(self, path: str = None) -> str:
186        """
187        Set or get the database path.
188
189        Parameters:
190        path (str): The path to the database file. If not provided, it returns the current path.
191
192        Returns:
193        str: The current database path.
194        """
195        if path is not None:
196            self._vault_path = path
197        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:
213    def reset(self) -> None:
214        """
215        Reset the internal data structure to its initial state.
216
217        Parameters:
218        None
219
220        Returns:
221        None
222        """
223        self._vault = {
224            'account': {},
225            'exchange': {},
226            'history': {},
227            'lock': None,
228            'report': {},
229        }

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:
231    @staticmethod
232    def time(now: datetime = None) -> int:
233        """
234        Generates a timestamp based on the provided datetime object or the current datetime.
235
236        Parameters:
237        now (datetime, optional): The datetime object to generate the timestamp from.
238        If not provided, the current datetime is used.
239
240        Returns:
241        int: The timestamp in positive nanoseconds since the Unix epoch (January 1, 1970),
242            before 1970 will return in negative until 1000AD.
243        """
244        if now is None:
245            now = datetime.datetime.now()
246        ordinal_day = now.toordinal()
247        ns_in_day = (now - now.replace(hour=0, minute=0, second=0, microsecond=0)).total_seconds() * 10 ** 9
248        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'>:
250    @staticmethod
251    def time_to_datetime(ordinal_ns: int) -> datetime:
252        """
253        Converts an ordinal number (number of days since 1000-01-01) to a datetime object.
254
255        Parameters:
256        ordinal_ns (int): The ordinal number of days since 1000-01-01.
257
258        Returns:
259        datetime: The corresponding datetime object.
260        """
261        ordinal_day = ordinal_ns // 86_400_000_000_000 + 719_163
262        ns_in_day = ordinal_ns % 86_400_000_000_000
263        d = datetime.datetime.fromordinal(ordinal_day)
264        t = datetime.timedelta(seconds=ns_in_day // 10 ** 9)
265        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:
303    def nolock(self) -> bool:
304        """
305        Check if the vault lock is currently not set.
306
307        :return: True if the vault lock is not set, False otherwise.
308        """
309        return self._vault['lock'] is None

Check if the vault lock is currently not set.

Returns

True if the vault lock is not set, False otherwise.

def lock(self) -> int:
311    def lock(self) -> int:
312        """
313        Acquires a lock on the ZakatTracker instance.
314
315        Returns:
316        int: The lock ID. This ID can be used to release the lock later.
317        """
318        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:
320    def box(self) -> dict:
321        """
322        Returns a copy of the internal vault dictionary.
323
324        This method is used to retrieve the current state of the ZakatTracker object.
325        It provides a snapshot of the internal data structure, allowing for further
326        processing or analysis.
327
328        :return: A copy of the internal vault dictionary.
329        """
330        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

A copy of the internal vault dictionary.

def steps(self) -> dict:
332    def steps(self) -> dict:
333        """
334        Returns a copy of the history of steps taken in the ZakatTracker.
335
336        The history is a dictionary where each key is a unique identifier for a step,
337        and the corresponding value is a dictionary containing information about the step.
338
339        :return: A copy of the history of steps taken in the ZakatTracker.
340        """
341        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

A copy of the history of steps taken in the ZakatTracker.

def free(self, lock: int, auto_save: bool = True) -> bool:
343    def free(self, lock: int, auto_save: bool = True) -> bool:
344        """
345        Releases the lock on the database.
346
347        Parameters:
348        lock (int): The lock ID to be released.
349        auto_save (bool): Whether to automatically save the database after releasing the lock.
350
351        Returns:
352        bool: True if the lock is successfully released and (optionally) saved, False otherwise.
353        """
354        if lock == self._vault['lock']:
355            self._vault['lock'] = None
356            if auto_save:
357                return self.save(self.path())
358            return True
359        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:
361    def account_exists(self, account) -> bool:
362        """
363        Check if the given account exists in the vault.
364
365        Parameters:
366        account (str): The account number to check.
367
368        Returns:
369        bool: True if the account exists, False otherwise.
370        """
371        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:
373    def box_size(self, account) -> int:
374        """
375        Calculate the size of the box for a specific account.
376
377        Parameters:
378        account (str): The account number for which the box size needs to be calculated.
379
380        Returns:
381        int: The size of the box for the given account. If the account does not exist, -1 is returned.
382        """
383        if self.account_exists(account):
384            return len(self._vault['account'][account]['box'])
385        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:
387    def log_size(self, account) -> int:
388        """
389        Get the size of the log for a specific account.
390
391        Parameters:
392        account (str): The account number for which the log size needs to be calculated.
393
394        Returns:
395        int: The size of the log for the given account. If the account does not exist, -1 is returned.
396        """
397        if self.account_exists(account):
398            return len(self._vault['account'][account]['log'])
399        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:
401    def recall(self, dry=True, debug=False) -> bool:
402        """
403        Revert the last operation.
404
405        Parameters:
406        dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
407        debug (bool): If True, the function will print debug information. Default is False.
408
409        Returns:
410        bool: True if the operation was successful, False otherwise.
411        """
412        if not self.nolock() or len(self._vault['history']) == 0:
413            return False
414        if len(self._vault['history']) <= 0:
415            return False
416        ref = sorted(self._vault['history'].keys())[-1]
417        if debug:
418            print('recall', ref)
419        memory = self._vault['history'][ref]
420        if debug:
421            print(type(memory), 'memory', memory)
422
423        limit = len(memory) + 1
424        sub_positive_log_negative = 0
425        for i in range(-1, -limit, -1):
426            x = memory[i]
427            if debug:
428                print(type(x), x)
429            match x['action']:
430                case Action.CREATE:
431                    if x['account'] is not None:
432                        if self.account_exists(x['account']):
433                            if debug:
434                                print('account', self._vault['account'][x['account']])
435                            assert len(self._vault['account'][x['account']]['box']) == 0
436                            assert self._vault['account'][x['account']]['balance'] == 0
437                            assert self._vault['account'][x['account']]['count'] == 0
438                            if dry:
439                                continue
440                            del self._vault['account'][x['account']]
441
442                case Action.TRACK:
443                    if x['account'] is not None:
444                        if self.account_exists(x['account']):
445                            if dry:
446                                continue
447                            self._vault['account'][x['account']]['balance'] -= x['value']
448                            self._vault['account'][x['account']]['count'] -= 1
449                            del self._vault['account'][x['account']]['box'][x['ref']]
450
451                case Action.LOG:
452                    if x['account'] is not None:
453                        if self.account_exists(x['account']):
454                            if x['ref'] in self._vault['account'][x['account']]['log']:
455                                if dry:
456                                    continue
457                                if sub_positive_log_negative == -x['value']:
458                                    self._vault['account'][x['account']]['count'] -= 1
459                                    sub_positive_log_negative = 0
460                                del self._vault['account'][x['account']]['log'][x['ref']]
461
462                case Action.SUB:
463                    if x['account'] is not None:
464                        if self.account_exists(x['account']):
465                            if x['ref'] in self._vault['account'][x['account']]['box']:
466                                if dry:
467                                    continue
468                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] += x['value']
469                                self._vault['account'][x['account']]['balance'] += x['value']
470                                sub_positive_log_negative = x['value']
471
472                case Action.ADD_FILE:
473                    if x['account'] is not None:
474                        if self.account_exists(x['account']):
475                            if x['ref'] in self._vault['account'][x['account']]['log']:
476                                if x['file'] in self._vault['account'][x['account']]['log'][x['ref']]['file']:
477                                    if dry:
478                                        continue
479                                    del self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']]
480
481                case Action.REMOVE_FILE:
482                    if x['account'] is not None:
483                        if self.account_exists(x['account']):
484                            if x['ref'] in self._vault['account'][x['account']]['log']:
485                                if dry:
486                                    continue
487                                self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']] = x['value']
488
489                case Action.BOX_TRANSFER:
490                    if x['account'] is not None:
491                        if self.account_exists(x['account']):
492                            if x['ref'] in self._vault['account'][x['account']]['box']:
493                                if dry:
494                                    continue
495                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] -= x['value']
496
497                case Action.EXCHANGE:
498                    if x['account'] is not None:
499                        if x['account'] in self._vault['exchange']:
500                            if x['ref'] in self._vault['exchange'][x['account']]:
501                                if dry:
502                                    continue
503                                del self._vault['exchange'][x['account']][x['ref']]
504
505                case Action.REPORT:
506                    if x['ref'] in self._vault['report']:
507                        if dry:
508                            continue
509                        del self._vault['report'][x['ref']]
510
511                case Action.ZAKAT:
512                    if x['account'] is not None:
513                        if self.account_exists(x['account']):
514                            if x['ref'] in self._vault['account'][x['account']]['box']:
515                                if x['key'] in self._vault['account'][x['account']]['box'][x['ref']]:
516                                    if dry:
517                                        continue
518                                    match x['math']:
519                                        case MathOperation.ADDITION:
520                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] -= x[
521                                                'value']
522                                        case MathOperation.EQUAL:
523                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] = x['value']
524                                        case MathOperation.SUBTRACTION:
525                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] += x[
526                                                'value']
527
528        if not dry:
529            del self._vault['history'][ref]
530        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:
532    def ref_exists(self, account: str, ref_type: str, ref: int) -> bool:
533        """
534        Check if a specific reference (transaction) exists in the vault for a given account and reference type.
535
536        Parameters:
537        account (str): The account number for which to check the existence of the reference.
538        ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
539        ref (int): The reference (transaction) number to check for existence.
540
541        Returns:
542        bool: True if the reference exists for the given account and reference type, False otherwise.
543        """
544        if account in self._vault['account']:
545            return ref in self._vault['account'][account][ref_type]
546        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:
548    def box_exists(self, account: str, ref: int) -> bool:
549        """
550        Check if a specific box (transaction) exists in the vault for a given account and reference.
551
552        Parameters:
553        - account (str): The account number for which to check the existence of the box.
554        - ref (int): The reference (transaction) number to check for existence.
555
556        Returns:
557        - bool: True if the box exists for the given account and reference, False otherwise.
558        """
559        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:
561    def track(self, value: float = 0, desc: str = '', account: str = 1, logging: bool = True, created: int = None,
562              debug: bool = False) -> int:
563        """
564        This function tracks a transaction for a specific account.
565
566        Parameters:
567        value (float): The value of the transaction. Default is 0.
568        desc (str): The description of the transaction. Default is an empty string.
569        account (str): The account for which the transaction is being tracked. Default is '1'.
570        logging (bool): Whether to log the transaction. Default is True.
571        created (int): The timestamp of the transaction. If not provided, it will be generated. Default is None.
572        debug (bool): Whether to print debug information. Default is False.
573
574        Returns:
575        int: The timestamp of the transaction.
576
577        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.
578
579        Raises:
580        ValueError: The log transaction happened again in the same nanosecond time.
581        ValueError: The box transaction happened again in the same nanosecond time.
582        """
583        if created is None:
584            created = self.time()
585        no_lock = self.nolock()
586        self.lock()
587        if not self.account_exists(account):
588            if debug:
589                print(f"account {account} created")
590            self._vault['account'][account] = {
591                'balance': 0,
592                'box': {},
593                'count': 0,
594                'log': {},
595                'zakatable': True,
596            }
597            self._step(Action.CREATE, account)
598        if value == 0:
599            if no_lock:
600                self.free(self.lock())
601            return 0
602        if logging:
603            self._log(value, desc, account, created, debug)
604        if debug:
605            print('create-box', created)
606        if self.box_exists(account, created):
607            raise ValueError(f"The box transaction happened again in the same nanosecond time({created}).")
608        if debug:
609            print('created-box', created)
610        self._vault['account'][account]['box'][created] = {
611            'capital': value,
612            'count': 0,
613            'last': 0,
614            'rest': value,
615            'total': 0,
616        }
617        self._step(Action.TRACK, account, ref=created, value=value)
618        if no_lock:
619            self.free(self.lock())
620        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:
622    def log_exists(self, account: str, ref: int) -> bool:
623        """
624        Checks if a specific transaction log entry exists for a given account.
625
626        Parameters:
627        account (str): The account number associated with the transaction log.
628        ref (int): The reference to the transaction log entry.
629
630        Returns:
631        bool: True if the transaction log entry exists, False otherwise.
632        """
633        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:
672    def exchange(self, account, created: int = None, rate: float = None, description: str = None,
673                 debug: bool = False) -> dict:
674        """
675        This method is used to record or retrieve exchange rates for a specific account.
676
677        Parameters:
678        - account (str): The account number for which the exchange rate is being recorded or retrieved.
679        - created (int): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
680        - rate (float): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
681        - description (str): A description of the exchange rate.
682
683        Returns:
684        - dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found,
685        it returns a dictionary with default values for the rate and description.
686        """
687        if created is None:
688            created = self.time()
689        no_lock = self.nolock()
690        self.lock()
691        if rate is not None:
692            if rate <= 0:
693                return dict()
694            if account not in self._vault['exchange']:
695                self._vault['exchange'][account] = {}
696            if len(self._vault['exchange'][account]) == 0 and rate <= 1:
697                return {"rate": 1, "description": None}
698            self._vault['exchange'][account][created] = {"rate": rate, "description": description}
699            self._step(Action.EXCHANGE, account, ref=created, value=rate)
700            if no_lock:
701                self.free(self.lock())
702            if debug:
703                print("exchange-created-1",
704                      f'account: {account}, created: {created}, rate:{rate}, description:{description}')
705
706        if account in self._vault['exchange']:
707            valid_rates = [(ts, r) for ts, r in self._vault['exchange'][account].items() if ts <= created]
708            if valid_rates:
709                latest_rate = max(valid_rates, key=lambda x: x[0])
710                if debug:
711                    print("exchange-read-1",
712                          f'account: {account}, created: {created}, rate:{rate}, description:{description}',
713                          'latest_rate', latest_rate)
714                return latest_rate[1]  # إرجاع قاموس يحتوي على المعدل والوصف
715        if debug:
716            print("exchange-read-0", f'account: {account}, created: {created}, rate:{rate}, description:{description}')
717        return {"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:
719    @staticmethod
720    def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
721        """
722        This function calculates the exchanged amount of a currency.
723
724        Args:
725            x (float): The original amount of the currency.
726            x_rate (float): The exchange rate of the original currency.
727            y_rate (float): The exchange rate of the target currency.
728
729        Returns:
730            float: The exchanged amount of the target currency.
731        """
732        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:
734    def exchanges(self) -> dict:
735        """
736        Retrieve the recorded exchange rates for all accounts.
737
738        Parameters:
739        None
740
741        Returns:
742        dict: A dictionary containing all recorded exchange rates.
743        The keys are account names or numbers, and the values are dictionaries containing the exchange rates.
744        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
745        """
746        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:
748    def accounts(self) -> dict:
749        """
750        Returns a dictionary containing account numbers as keys and their respective balances as values.
751
752        Parameters:
753        None
754
755        Returns:
756        dict: A dictionary where keys are account numbers and values are their respective balances.
757        """
758        result = {}
759        for i in self._vault['account']:
760            result[i] = self._vault['account'][i]['balance']
761        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:
763    def boxes(self, account) -> dict:
764        """
765        Retrieve the boxes (transactions) associated with a specific account.
766
767        Parameters:
768        account (str): The account number for which to retrieve the boxes.
769
770        Returns:
771        dict: A dictionary containing the boxes associated with the given account.
772        If the account does not exist, an empty dictionary is returned.
773        """
774        if self.account_exists(account):
775            return self._vault['account'][account]['box']
776        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:
778    def logs(self, account) -> dict:
779        """
780        Retrieve the logs (transactions) associated with a specific account.
781
782        Parameters:
783        account (str): The account number for which to retrieve the logs.
784
785        Returns:
786        dict: A dictionary containing the logs associated with the given account.
787        If the account does not exist, an empty dictionary is returned.
788        """
789        if self.account_exists(account):
790            return self._vault['account'][account]['log']
791        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:
793    def add_file(self, account: str, ref: int, path: str) -> int:
794        """
795        Adds a file reference to a specific transaction log entry in the vault.
796
797        Parameters:
798        account (str): The account number associated with the transaction log.
799        ref (int): The reference to the transaction log entry.
800        path (str): The path of the file to be added.
801
802        Returns:
803        int: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
804        """
805        if self.account_exists(account):
806            if ref in self._vault['account'][account]['log']:
807                file_ref = self.time()
808                self._vault['account'][account]['log'][ref]['file'][file_ref] = path
809                no_lock = self.nolock()
810                self.lock()
811                self._step(Action.ADD_FILE, account, ref=ref, file=file_ref)
812                if no_lock:
813                    self.free(self.lock())
814                return file_ref
815        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:
817    def remove_file(self, account: str, ref: int, file_ref: int) -> bool:
818        """
819        Removes a file reference from a specific transaction log entry in the vault.
820
821        Parameters:
822        account (str): The account number associated with the transaction log.
823        ref (int): The reference to the transaction log entry.
824        file_ref (int): The reference of the file to be removed.
825
826        Returns:
827        bool: True if the file reference is successfully removed, False otherwise.
828        """
829        if self.account_exists(account):
830            if ref in self._vault['account'][account]['log']:
831                if file_ref in self._vault['account'][account]['log'][ref]['file']:
832                    x = self._vault['account'][account]['log'][ref]['file'][file_ref]
833                    del self._vault['account'][account]['log'][ref]['file'][file_ref]
834                    no_lock = self.nolock()
835                    self.lock()
836                    self._step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x)
837                    if no_lock:
838                        self.free(self.lock())
839                    return True
840        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:
842    def balance(self, account: str = 1, cached: bool = True) -> int:
843        """
844        Calculate and return the balance of a specific account.
845
846        Parameters:
847        account (str): The account number. Default is '1'.
848        cached (bool): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
849
850        Returns:
851        int: The balance of the account.
852
853        Note:
854        If cached is True, the function returns the cached balance.
855        If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
856        """
857        if cached:
858            return self._vault['account'][account]['balance']
859        x = 0
860        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 zakatable(self, account, status: bool = None) -> bool:
862    def zakatable(self, account, status: bool = None) -> bool:
863        """
864        Check or set the zakatable status of a specific account.
865
866        Parameters:
867        account (str): The account number.
868        status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
869
870        Returns:
871        bool: The current or updated zakatable status of the account.
872
873        Raises:
874        None
875
876        Example:
877        >>> tracker = ZakatTracker()
878        >>> ref = tracker.track(51, 'desc', 'account1')
879        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
880        True
881        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
882        True
883        >>> tracker.zakatable('account1', False)
884        False
885        """
886        if self.account_exists(account):
887            if status is None:
888                return self._vault['account'][account]['zakatable']
889            self._vault['account'][account]['zakatable'] = status
890            return status
891        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', 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:
893    def sub(self, x: float, desc: str = '', account: str = 1, created: int = None, debug: bool = False) -> tuple:
894        """
895        Subtracts a specified value from an account's balance.
896
897        Parameters:
898        x (float): The amount to be subtracted.
899        desc (str): A description for the transaction. Defaults to an empty string.
900        account (str): The account from which the value will be subtracted. Defaults to '1'.
901        created (int): The timestamp of the transaction. If not provided, the current timestamp will be used.
902        debug (bool): A flag indicating whether to print debug information. Defaults to False.
903
904        Returns:
905        tuple: A tuple containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
906
907        If the amount to subtract is greater than the account's balance,
908        the remaining amount will be transferred to a new transaction with a negative value.
909
910        Raises:
911        ValueError: The box transaction happened again in the same nanosecond time.
912        ValueError: The log transaction happened again in the same nanosecond time.
913        """
914        if x < 0:
915            return tuple()
916        if x == 0:
917            ref = self.track(x, '', account)
918            return ref, ref
919        if created is None:
920            created = self.time()
921        no_lock = self.nolock()
922        self.lock()
923        self.track(0, '', account)
924        self._log(-x, desc, account, created)
925        ids = sorted(self._vault['account'][account]['box'].keys())
926        limit = len(ids) + 1
927        target = x
928        if debug:
929            print('ids', ids)
930        ages = []
931        for i in range(-1, -limit, -1):
932            if target == 0:
933                break
934            j = ids[i]
935            if debug:
936                print('i', i, 'j', j)
937            rest = self._vault['account'][account]['box'][j]['rest']
938            if rest >= target:
939                self._vault['account'][account]['box'][j]['rest'] -= target
940                self._step(Action.SUB, account, ref=j, value=target)
941                ages.append((j, target))
942                target = 0
943                break
944            elif rest < target and rest > 0:
945                chunk = rest
946                target -= chunk
947                self._step(Action.SUB, account, ref=j, value=chunk)
948                ages.append((j, chunk))
949                self._vault['account'][account]['box'][j]['rest'] = 0
950        if target > 0:
951            self.track(-target, desc, account, False, created)
952            ages.append((created, target))
953        if no_lock:
954            self.free(self.lock())
955        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]:
 957    def transfer(self, amount: int, from_account: str, to_account: str, desc: str = '', created: int = None,
 958                 debug: bool = False) -> list[int]:
 959        """
 960        Transfers a specified value from one account to another.
 961
 962        Parameters:
 963        amount (int): The amount to be transferred.
 964        from_account (str): The account from which the value will be transferred.
 965        to_account (str): The account to which the value will be transferred.
 966        desc (str, optional): A description for the transaction. Defaults to an empty string.
 967        created (int, optional): The timestamp of the transaction. If not provided, the current timestamp will be used.
 968        debug (bool): A flag indicating whether to print debug information. Defaults to False.
 969
 970        Returns:
 971        list[int]: A list of timestamps corresponding to the transactions made during the transfer.
 972
 973        Raises:
 974        ValueError: The box transaction happened again in the same nanosecond time.
 975        ValueError: The log transaction happened again in the same nanosecond time.
 976        """
 977        if amount <= 0:
 978            return []
 979        if created is None:
 980            created = self.time()
 981        (_, ages) = self.sub(amount, desc, from_account, created, debug=debug)
 982        times = []
 983        source_exchange = self.exchange(from_account, created)
 984        target_exchange = self.exchange(to_account, created)
 985
 986        if debug:
 987            print('ages', ages)
 988
 989        for age, value in ages:
 990            target_amount = self.exchange_calc(value, source_exchange['rate'], target_exchange['rate'])
 991            # Perform the transfer
 992            if self.box_exists(to_account, age):
 993                if debug:
 994                    print('box_exists', age)
 995                capital = self._vault['account'][to_account]['box'][age]['capital']
 996                rest = self._vault['account'][to_account]['box'][age]['rest']
 997                if debug:
 998                    print(
 999                        f"Transfer {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1000                selected_age = age
1001                if rest + target_amount > capital:
1002                    self._vault['account'][to_account]['box'][age]['capital'] += target_amount
1003                    selected_age = ZakatTracker.time()
1004                self._vault['account'][to_account]['box'][age]['rest'] += target_amount
1005                self._step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount)
1006                y = self._log(target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account,
1007                              debug=debug)
1008                times.append((age, y))
1009                continue
1010            y = self.track(target_amount, desc, to_account, logging=True, created=age, debug=debug)
1011            if debug:
1012                print(
1013                    f"Transferred {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1014            times.append(y)
1015        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: 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:
1017    def check(self, silver_gram_price: float, nisab: float = None, debug: bool = False, now: int = None,
1018              cycle: float = None) -> tuple:
1019        """
1020        Check the eligibility for Zakat based on the given parameters.
1021
1022        Parameters:
1023        silver_gram_price (float): The price of a gram of silver.
1024        nisab (float): The minimum amount of wealth required for Zakat. If not provided,
1025                        it will be calculated based on the silver_gram_price.
1026        debug (bool): Flag to enable debug mode.
1027        now (int): The current timestamp. If not provided, it will be calculated using ZakatTracker.time().
1028        cycle (float): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
1029
1030        Returns:
1031        tuple: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics,
1032        and a dictionary containing the Zakat plan.
1033        """
1034        if now is None:
1035            now = self.time()
1036        if cycle is None:
1037            cycle = ZakatTracker.TimeCycle()
1038        if nisab is None:
1039            nisab = ZakatTracker.Nisab(silver_gram_price)
1040        plan = {}
1041        below_nisab = 0
1042        brief = [0, 0, 0]
1043        valid = False
1044        for x in self._vault['account']:
1045            if not self.zakatable(x):
1046                continue
1047            _box = self._vault['account'][x]['box']
1048            limit = len(_box) + 1
1049            ids = sorted(self._vault['account'][x]['box'].keys())
1050            for i in range(-1, -limit, -1):
1051                j = ids[i]
1052                rest = _box[j]['rest']
1053                if rest <= 0:
1054                    continue
1055                exchange = self.exchange(x, created=j)
1056                if debug:
1057                    print('exchanges', self.exchanges())
1058                rest = ZakatTracker.exchange_calc(rest, exchange['rate'], 1)
1059                brief[0] += rest
1060                index = limit + i - 1
1061                epoch = (now - j) / cycle
1062                if debug:
1063                    print(f"Epoch: {epoch}", _box[j])
1064                if _box[j]['last'] > 0:
1065                    epoch = (now - _box[j]['last']) / cycle
1066                if debug:
1067                    print(f"Epoch: {epoch}")
1068                epoch = floor(epoch)
1069                if debug:
1070                    print(f"Epoch: {epoch}", type(epoch), epoch == 0, 1 - epoch, epoch)
1071                if epoch == 0:
1072                    continue
1073                if debug:
1074                    print("Epoch - PASSED")
1075                brief[1] += rest
1076                if rest >= nisab:
1077                    total = 0
1078                    for _ in range(epoch):
1079                        total += ZakatTracker.ZakatCut(rest - total)
1080                    if total > 0:
1081                        if x not in plan:
1082                            plan[x] = {}
1083                        valid = True
1084                        brief[2] += total
1085                        plan[x][index] = {'total': total, 'count': epoch}
1086                else:
1087                    chunk = ZakatTracker.ZakatCut(rest)
1088                    if chunk > 0:
1089                        if x not in plan:
1090                            plan[x] = {}
1091                        if j not in plan[x].keys():
1092                            plan[x][index] = {}
1093                        below_nisab += rest
1094                        brief[2] += chunk
1095                        plan[x][index]['below_nisab'] = chunk
1096        valid = valid or below_nisab >= nisab
1097        if debug:
1098            print(f"below_nisab({below_nisab}) >= nisab({nisab})")
1099        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:
1101    def build_payment_parts(self, demand: float, positive_only: bool = True) -> dict:
1102        """
1103        Build payment parts for the zakat distribution.
1104
1105        Parameters:
1106        demand (float): The total demand for payment in local currency.
1107        positive_only (bool): If True, only consider accounts with positive balance. Default is True.
1108
1109        Returns:
1110        dict: A dictionary containing the payment parts for each account. The dictionary has the following structure:
1111        {
1112            'account': {
1113                'account_id': {'balance': float, 'rate': float, 'part': float},
1114                ...
1115            },
1116            'exceed': bool,
1117            'demand': float,
1118            'total': float,
1119        }
1120        """
1121        total = 0
1122        parts = {
1123            'account': {},
1124            'exceed': False,
1125            'demand': demand,
1126        }
1127        for x, y in self.accounts().items():
1128            if positive_only and y <= 0:
1129                continue
1130            total += y
1131            exchange = self.exchange(x)
1132            parts['account'][x] = {'balance': y, 'rate': exchange['rate'], 'part': 0}
1133        parts['total'] = total
1134        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) -> int:
1136    @staticmethod
1137    def check_payment_parts(parts: dict) -> int:
1138        """
1139        Checks the validity of payment parts.
1140
1141        Parameters:
1142        parts (dict): A dictionary containing payment parts information.
1143
1144        Returns:
1145        int: Returns 0 if the payment parts are valid, otherwise returns the error code.
1146
1147        Error Codes:
1148        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
1149        2: 'balance', 'rate' or 'part' key is missing in parts['account'][x].
1150        3: 'part' value in parts['account'][x] is less than or equal to 0.
1151        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
1152        5: 'part' value in parts['account'][x] is less than 0.
1153        6: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
1154        7: The sum of 'part' values in parts['account'] does not match with 'demand' value.
1155        """
1156        for i in ['demand', 'account', 'total', 'exceed']:
1157            if not i in parts:
1158                return 1
1159        exceed = parts['exceed']
1160        for x in parts['account']:
1161            for j in ['balance', 'rate', 'part']:
1162                if not j in parts['account'][x]:
1163                    return 2
1164                if parts['account'][x]['part'] <= 0:
1165                    return 3
1166                if not exceed and parts['account'][x]['balance'] <= 0:
1167                    return 4
1168        demand = parts['demand']
1169        z = 0
1170        for _, y in parts['account'].items():
1171            if y['part'] < 0:
1172                return 5
1173            if not exceed and y['part'] > y['balance']:
1174                return 6
1175            z += ZakatTracker.exchange_calc(y['part'], y['rate'], 1)
1176        if z != demand:
1177            return 7
1178        return 0

Checks the validity of payment parts.

Parameters: parts (dict): A dictionary containing payment parts information.

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 or equal to 0. 4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0. 5: 'part' value in parts['account'][x] is less than 0. 6: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value. 7: The sum of 'part' values in parts['account'] does not match with 'demand' value.

def zakat(self, report: tuple, parts: dict = None, debug: bool = False) -> bool:
1180    def zakat(self, report: tuple, parts: dict = None, debug: bool = False) -> bool:
1181        """
1182        Perform Zakat calculation based on the given report and optional parts.
1183
1184        Parameters:
1185        report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan.
1186        parts (dict): A dictionary containing the payment parts for the zakat.
1187        debug (bool): A flag indicating whether to print debug information.
1188
1189        Returns:
1190        bool: True if the zakat calculation is successful, False otherwise.
1191        """
1192        valid, _, plan = report
1193        if not valid:
1194            return valid
1195        parts_exist = parts is not None
1196        if parts_exist:
1197            for part in parts:
1198                if self.check_payment_parts(part) != 0:
1199                    return False
1200        if debug:
1201            print('######### zakat #######')
1202            print('parts_exist', parts_exist)
1203        no_lock = self.nolock()
1204        self.lock()
1205        report_time = self.time()
1206        self._vault['report'][report_time] = report
1207        self._step(Action.REPORT, ref=report_time)
1208        created = self.time()
1209        for x in plan:
1210            if debug:
1211                print(plan[x])
1212                print('-------------')
1213                print(self._vault['account'][x]['box'])
1214            ids = sorted(self._vault['account'][x]['box'].keys())
1215            if debug:
1216                print('plan[x]', plan[x])
1217            for i in plan[x].keys():
1218                j = ids[i]
1219                if debug:
1220                    print('i', i, 'j', j)
1221                self._step(Action.ZAKAT, account=x, ref=j, value=self._vault['account'][x]['box'][j]['last'],
1222                           key='last',
1223                           math_operation=MathOperation.EQUAL)
1224                self._vault['account'][x]['box'][j]['last'] = created
1225                self._vault['account'][x]['box'][j]['total'] += plan[x][i]['total']
1226                self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['total'], key='total',
1227                           math_operation=MathOperation.ADDITION)
1228                self._vault['account'][x]['box'][j]['count'] += plan[x][i]['count']
1229                self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['count'], key='count',
1230                           math_operation=MathOperation.ADDITION)
1231                if not parts_exist:
1232                    self._vault['account'][x]['box'][j]['rest'] -= plan[x][i]['total']
1233                    self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['total'], key='rest',
1234                               math_operation=MathOperation.SUBTRACTION)
1235        if parts_exist:
1236            for transaction in parts:
1237                for account, part in transaction['account'].items():
1238                    if debug:
1239                        print('zakat-part', account, part['part'])
1240                    target_exchange = self.exchange(account)
1241                    amount = ZakatTracker.exchange_calc(part['part'], part['rate'], target_exchange['rate'])
1242                    self.sub(amount, desc='zakat-part', account=account, debug=debug)
1243        if no_lock:
1244            self.free(self.lock())
1245        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:
1247    def export_json(self, path: str = "data.json") -> bool:
1248        """
1249        Exports the current state of the ZakatTracker object to a JSON file.
1250
1251        Parameters:
1252        path (str): The path where the JSON file will be saved. Default is "data.json".
1253
1254        Returns:
1255        bool: True if the export is successful, False otherwise.
1256
1257        Raises:
1258        No specific exceptions are raised by this method.
1259        """
1260        with open(path, "w") as file:
1261            json.dump(self._vault, file, indent=4, cls=JSONEncoder)
1262            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:
1264    def save(self, path: str = None) -> bool:
1265        """
1266        Saves the ZakatTracker's current state to a pickle file.
1267
1268        This method serializes the internal data (`_vault`) along with metadata
1269        (Python version, pickle protocol) for future compatibility.
1270
1271        Parameters:
1272            path (str, optional): File path for saving. Defaults to a predefined location.
1273
1274        Returns:
1275            bool: True if the save operation is successful, False otherwise.
1276        """
1277        if path is None:
1278            path = self.path()
1279        with open(path, "wb") as f:
1280            version = f'{version_info.major}.{version_info.minor}.{version_info.micro}'
1281            pickle_protocol = pickle.HIGHEST_PROTOCOL
1282            data = {
1283                'python_version': version,
1284                'pickle_protocol': pickle_protocol,
1285                'data': self._vault,
1286            }
1287            pickle.dump(data, f, protocol=pickle_protocol)
1288            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:
1290    def load(self, path: str = None) -> bool:
1291        """
1292        Load the current state of the ZakatTracker object from a pickle file.
1293
1294        Parameters:
1295        path (str): The path where the pickle file is located. If not provided, it will use the default path.
1296
1297        Returns:
1298        bool: True if the load operation is successful, False otherwise.
1299        """
1300        if path is None:
1301            path = self.path()
1302        if os.path.exists(path):
1303            with open(path, "rb") as f:
1304                data = pickle.load(f)
1305                self._vault = data['data']
1306                return True
1307        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):
1309    def import_csv_cache_path(self):
1310        """
1311        Generates the cache file path for imported CSV data.
1312
1313        This function constructs the file path where cached data from CSV imports
1314        will be stored. The cache file is a pickle file (.pickle extension) appended
1315        to the base path of the object.
1316
1317        Returns:
1318            str: The full path to the import CSV cache file.
1319
1320        Example:
1321            >>> obj = ZakatTracker('/data/reports')
1322            >>> obj.import_csv_cache_path()
1323            '/data/reports.import_csv.pickle'
1324        """
1325        path = self.path()
1326        if path.endswith(".pickle"):
1327            path = path[:-7]
1328        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:
1330    def import_csv(self, path: str = 'file.csv', debug: bool = False) -> tuple:
1331        """
1332        The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system.
1333
1334        Parameters:
1335        path (str): The path to the CSV file. Default is 'file.csv'.
1336        debug (bool): A flag indicating whether to print debug information.
1337
1338        Returns:
1339        tuple: A tuple containing the number of transactions created, the number of transactions found in the cache,
1340                and a dictionary of bad transactions.
1341
1342        Notes:
1343            * Currency Pair Assumption: This function assumes that the exchange rates stored for each account
1344                                        are appropriate for the currency pairs involved in the conversions.
1345            * The exchange rate for each account is based on the last encountered transaction rate that is not equal
1346                to 1.0 or the previous rate for that account.
1347            * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent
1348              transactions of the same account within the whole imported and existing dataset when doing `check` and
1349              `zakat` operations.
1350
1351        Example Usage:
1352            The CSV file should have the following format, rate is optional per transaction:
1353            account, desc, value, date, rate
1354            For example:
1355            safe-45, "Some text", 34872, 1988-06-30 00:00:00, 1
1356        """
1357        cache: list[int] = []
1358        try:
1359            with open(self.import_csv_cache_path(), "rb") as f:
1360                cache = pickle.load(f)
1361        except:
1362            pass
1363        date_formats = [
1364            "%Y-%m-%d %H:%M:%S",
1365            "%Y-%m-%dT%H:%M:%S",
1366            "%Y-%m-%dT%H%M%S",
1367            "%Y-%m-%d",
1368        ]
1369        created, found, bad = 0, 0, {}
1370        data: list[tuple] = []
1371        with open(path, newline='', encoding="utf-8") as f:
1372            i = 0
1373            for row in csv.reader(f, delimiter=','):
1374                i += 1
1375                hashed = hash(tuple(row))
1376                if hashed in cache:
1377                    found += 1
1378                    continue
1379                account = row[0]
1380                desc = row[1]
1381                value = float(row[2])
1382                rate = 1.0
1383                if row[4:5]:  # Empty list if index is out of range
1384                    rate = float(row[4])
1385                date: int = 0
1386                for time_format in date_formats:
1387                    try:
1388                        date = self.time(datetime.datetime.strptime(row[3], time_format))
1389                        break
1390                    except:
1391                        pass
1392                # TODO: not allowed for negative dates
1393                if date == 0 or value == 0:
1394                    bad[i] = row
1395                    continue
1396                if date in data:
1397                    print('import_csv-duplicated(time)', date)
1398                    continue
1399                data.append((date, value, desc, account, rate, hashed))
1400
1401        if debug:
1402            print('import_csv', len(data))
1403        for row in sorted(data, key=lambda x: x[0]):
1404            (date, value, desc, account, rate, hashed) = row
1405            if rate > 1:
1406                self.exchange(account, created=date, rate=rate)
1407            if value > 0:
1408                self.track(value, desc, account, True, date)
1409            elif value < 0:
1410                self.sub(-value, desc, account, date)
1411            created += 1
1412            cache.append(hashed)
1413        with open(self.import_csv_cache_path(), "wb") as f:
1414            pickle.dump(cache, f)
1415        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:
1421    @staticmethod
1422    def duration_from_nanoseconds(ns: int) -> tuple:
1423        """
1424        REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
1425        Convert NanoSeconds to Human Readable Time Format.
1426        A NanoSeconds is a unit of time in the International System of Units (SI) equal
1427        to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
1428        Its symbol is μs, sometimes simplified to us when Unicode is not available.
1429        A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
1430
1431        INPUT : ms (AKA: MilliSeconds)
1432        OUTPUT: tuple(string time_lapsed, string spoken_time) like format.
1433        OUTPUT Variables: time_lapsed, spoken_time
1434
1435        Example  Input: duration_from_nanoseconds(ns)
1436        **"Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds"**
1437        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')
1438        duration_from_nanoseconds(1234567890123456789012)
1439        """
1440        us, ns = divmod(ns, 1000)
1441        ms, us = divmod(us, 1000)
1442        s, ms = divmod(ms, 1000)
1443        m, s = divmod(s, 60)
1444        h, m = divmod(m, 60)
1445        d, h = divmod(h, 24)
1446        y, d = divmod(d, 365)
1447        c, y = divmod(y, 100)
1448        n, c = divmod(c, 10)
1449        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}"
1450        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"
1451        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:
1453    @staticmethod
1454    def day_to_time(day: int, month: int = 6, year: int = 2024) -> int:  # افتراض أن الشهر هو يونيو والسنة 2024
1455        """
1456        Convert a specific day, month, and year into a timestamp.
1457
1458        Parameters:
1459        day (int): The day of the month.
1460        month (int): The month of the year. Default is 6 (June).
1461        year (int): The year. Default is 2024.
1462
1463        Returns:
1464        int: The timestamp representing the given day, month, and year.
1465
1466        Note:
1467        This method assumes the default month and year if not provided.
1468        """
1469        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:
1471    @staticmethod
1472    def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
1473        """
1474        Generate a random date between two given dates.
1475
1476        Parameters:
1477        start_date (datetime.datetime): The start date from which to generate a random date.
1478        end_date (datetime.datetime): The end date until which to generate a random date.
1479
1480        Returns:
1481        datetime.datetime: A random date between the start_date and end_date.
1482        """
1483        time_between_dates = end_date - start_date
1484        days_between_dates = time_between_dates.days
1485        random_number_of_days = random.randrange(days_between_dates)
1486        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:
1488    @staticmethod
1489    def generate_random_csv_file(path: str = "data.csv", count: int = 1000, with_rate: bool = False, debug: bool = False) -> int:
1490        """
1491        Generate a random CSV file with specified parameters.
1492
1493        Parameters:
1494        path (str): The path where the CSV file will be saved. Default is "data.csv".
1495        count (int): The number of rows to generate in the CSV file. Default is 1000.
1496        with_rate (bool): If True, a random rate between 1.2% and 12% is added. Default is False.
1497        debug (bool): A flag indicating whether to print debug information.
1498
1499        Returns:
1500        None. The function generates a CSV file at the specified path with the given count of rows.
1501        Each row contains a randomly generated account, description, value, and date.
1502        The value is randomly generated between 1000 and 100000,
1503        and the date is randomly generated between 1950-01-01 and 2023-12-31.
1504        If the row number is not divisible by 13, the value is multiplied by -1.
1505        """
1506        i = 0
1507        with open(path, "w", newline="") as csvfile:
1508            writer = csv.writer(csvfile)
1509            for i in range(count):
1510                account = f"acc-{random.randint(1, 1000)}"
1511                desc = f"Some text {random.randint(1, 1000)}"
1512                value = random.randint(1000, 100000)
1513                date = ZakatTracker.generate_random_date(datetime.datetime(1950, 1, 1),
1514                                                         datetime.datetime(2023, 12, 31)).strftime("%Y-%m-%d %H:%M:%S")
1515                if not i % 13 == 0:
1516                    value *= -1
1517                row = [account, desc, value, date]
1518                if with_rate:
1519                    rate = random.randint(1,100) * 0.12
1520                    if debug:
1521                        print('before-append', row)
1522                    row.append(rate)
1523                    if debug:
1524                        print('after-append', row)
1525                writer.writerow(row)
1526                i = i + 1
1527        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):
1529    @staticmethod
1530    def create_random_list(max_sum, min_value=0, max_value=10):
1531        """
1532        Creates a list of random integers whose sum does not exceed the specified maximum.
1533
1534        Args:
1535            max_sum: The maximum allowed sum of the list elements.
1536            min_value: The minimum possible value for an element (inclusive).
1537            max_value: The maximum possible value for an element (inclusive).
1538
1539        Returns:
1540            A list of random integers.
1541        """
1542        result = []
1543        current_sum = 0
1544
1545        while current_sum < max_sum:
1546            # Calculate the remaining space for the next element
1547            remaining_sum = max_sum - current_sum
1548            # Determine the maximum possible value for the next element
1549            next_max_value = min(remaining_sum, max_value)
1550            # Generate a random element within the allowed range
1551            next_element = random.randint(min_value, next_max_value)
1552            result.append(next_element)
1553            current_sum += next_element
1554
1555        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:
1695    def test(self, debug: bool = False) -> bool:
1696
1697        try:
1698
1699            assert self._history()
1700
1701            # Not allowed for duplicate transactions in the same account and time
1702
1703            created = ZakatTracker.time()
1704            self.track(100, 'test-1', 'same', True, created)
1705            failed = False
1706            try:
1707                self.track(50, 'test-1', 'same', True, created)
1708            except:
1709                failed = True
1710            assert failed is True
1711
1712            self.reset()
1713
1714            # Always preserve box age during transfer
1715
1716            series: list[tuple] = [
1717                (30, 4),
1718                (60, 3),
1719                (90, 2),
1720            ]
1721            case = {
1722                30: {
1723                    'series': series,
1724                    'rest': 150,
1725                },
1726                60: {
1727                    'series': series,
1728                    'rest': 120,
1729                },
1730                90: {
1731                    'series': series,
1732                    'rest': 90,
1733                },
1734                180: {
1735                    'series': series,
1736                    'rest': 0,
1737                },
1738                270: {
1739                    'series': series,
1740                    'rest': -90,
1741                },
1742                360: {
1743                    'series': series,
1744                    'rest': -180,
1745                },
1746            }
1747
1748            selected_time = ZakatTracker.time() - ZakatTracker.TimeCycle()
1749
1750            for total in case:
1751                for x in case[total]['series']:
1752                    self.track(x[0], f"test-{x} ages", 'ages', True, selected_time * x[1])
1753
1754                refs = self.transfer(total, 'ages', 'future', 'Zakat Movement', debug=debug)
1755
1756                if debug:
1757                    print('refs', refs)
1758
1759                ages_cache_balance = self.balance('ages')
1760                ages_fresh_balance = self.balance('ages', False)
1761                rest = case[total]['rest']
1762                if debug:
1763                    print('source', ages_cache_balance, ages_fresh_balance, rest)
1764                assert ages_cache_balance == rest
1765                assert ages_fresh_balance == rest
1766
1767                future_cache_balance = self.balance('future')
1768                future_fresh_balance = self.balance('future', False)
1769                if debug:
1770                    print('target', future_cache_balance, future_fresh_balance, total)
1771                    print('refs', refs)
1772                assert future_cache_balance == total
1773                assert future_fresh_balance == total
1774
1775                for ref in self._vault['account']['ages']['box']:
1776                    ages_capital = self._vault['account']['ages']['box'][ref]['capital']
1777                    ages_rest = self._vault['account']['ages']['box'][ref]['rest']
1778                    future_capital = 0
1779                    if ref in self._vault['account']['future']['box']:
1780                        future_capital = self._vault['account']['future']['box'][ref]['capital']
1781                    future_rest = 0
1782                    if ref in self._vault['account']['future']['box']:
1783                        future_rest = self._vault['account']['future']['box'][ref]['rest']
1784                    if ages_capital != 0 and future_capital != 0 and future_rest != 0:
1785                        if debug:
1786                            print('================================================================')
1787                            print('ages', ages_capital, ages_rest)
1788                            print('future', future_capital, future_rest)
1789                        if ages_rest == 0:
1790                            assert ages_capital == future_capital
1791                        elif ages_rest < 0:
1792                            assert -ages_capital == future_capital
1793                        elif ages_rest > 0:
1794                            assert ages_capital == ages_rest + future_capital
1795                self.reset()
1796                assert len(self._vault['history']) == 0
1797
1798            assert self._history()
1799            assert self._history(False) is False
1800            assert self._history() is False
1801            assert self._history(True)
1802            assert self._history()
1803
1804            self._test_core(True, debug)
1805            self._test_core(False, debug)
1806
1807            transaction = [
1808                (
1809                    20, 'wallet', 1, 800, 800, 800, 4, 5,
1810                    -85, -85, -85, 6, 7,
1811                ),
1812                (
1813                    750, 'wallet', 'safe', 50, 50, 50, 4, 6,
1814                    750, 750, 750, 1, 1,
1815                ),
1816                (
1817                    600, 'safe', 'bank', 150, 150, 150, 1, 2,
1818                    600, 600, 600, 1, 1,
1819                ),
1820            ]
1821            for z in transaction:
1822                self.lock()
1823                x = z[1]
1824                y = z[2]
1825                self.transfer(z[0], x, y, 'test-transfer', debug=debug)
1826                assert self.balance(x) == z[3]
1827                xx = self.accounts()[x]
1828                assert xx == z[3]
1829                assert self.balance(x, False) == z[4]
1830                assert xx == z[4]
1831
1832                s = 0
1833                log = self._vault['account'][x]['log']
1834                for i in log:
1835                    s += log[i]['value']
1836                if debug:
1837                    print('s', s, 'z[5]', z[5])
1838                assert s == z[5]
1839
1840                assert self.box_size(x) == z[6]
1841                assert self.log_size(x) == z[7]
1842
1843                yy = self.accounts()[y]
1844                assert self.balance(y) == z[8]
1845                assert yy == z[8]
1846                assert self.balance(y, False) == z[9]
1847                assert yy == z[9]
1848
1849                s = 0
1850                log = self._vault['account'][y]['log']
1851                for i in log:
1852                    s += log[i]['value']
1853                assert s == z[10]
1854
1855                assert self.box_size(y) == z[11]
1856                assert self.log_size(y) == z[12]
1857
1858            if debug:
1859                pp().pprint(self.check(2.17))
1860
1861            assert not self.nolock()
1862            history_count = len(self._vault['history'])
1863            if debug:
1864                print('history-count', history_count)
1865            assert history_count == 11
1866            assert not self.free(ZakatTracker.time())
1867            assert self.free(self.lock())
1868            assert self.nolock()
1869            assert len(self._vault['history']) == 11
1870
1871            # storage
1872
1873            _path = self.path('test.pickle')
1874            if os.path.exists(_path):
1875                os.remove(_path)
1876            self.save()
1877            assert os.path.getsize(_path) > 0
1878            self.reset()
1879            assert self.recall(False, debug) is False
1880            self.load()
1881            assert self._vault['account'] is not None
1882
1883            # recall
1884
1885            assert self.nolock()
1886            assert len(self._vault['history']) == 11
1887            assert self.recall(False, debug) is True
1888            assert len(self._vault['history']) == 10
1889            assert self.recall(False, debug) is True
1890            assert len(self._vault['history']) == 9
1891
1892            csv_count = 1000
1893
1894            for with_rate, path in {
1895                False: 'test-import_csv-no-exchange',
1896                True: 'test-import_csv-with-exchange',
1897            }.items():
1898
1899                if debug:
1900                    print('test_import_csv', with_rate, path)
1901
1902                # csv
1903
1904                csv_path = path + '.csv'
1905                if os.path.exists(csv_path):
1906                    os.remove(csv_path)
1907                c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug)
1908                if debug:
1909                    print('generate_random_csv_file', c)
1910                assert c == csv_count
1911                assert os.path.getsize(csv_path) > 0
1912                cache_path = self.import_csv_cache_path()
1913                if os.path.exists(cache_path):
1914                    os.remove(cache_path)
1915                self.reset()
1916                (created, found, bad) = self.import_csv(csv_path, debug)
1917                bad_count = len(bad)
1918                if debug:
1919                    print(f"csv-imported: ({created}, {found}, {bad_count}) = count({csv_count})")
1920                tmp_size = os.path.getsize(cache_path)
1921                assert tmp_size > 0
1922                assert created + found + bad_count == csv_count
1923                assert created == csv_count
1924                assert bad_count == 0
1925                (created_2, found_2, bad_2) = self.import_csv(csv_path)
1926                bad_2_count = len(bad_2)
1927                if debug:
1928                    print(f"csv-imported: ({created_2}, {found_2}, {bad_2_count})")
1929                    print(bad)
1930                assert tmp_size == os.path.getsize(cache_path)
1931                assert created_2 + found_2 + bad_2_count == csv_count
1932                assert created == found_2
1933                assert bad_count == bad_2_count
1934                assert found_2 == csv_count
1935                assert bad_2_count == 0
1936                assert created_2 == 0
1937
1938                # payment parts
1939
1940                positive_parts = self.build_payment_parts(100, positive_only=True)
1941                assert self.check_payment_parts(positive_parts) != 0
1942                assert self.check_payment_parts(positive_parts) != 0
1943                all_parts = self.build_payment_parts(300, positive_only=False)
1944                assert self.check_payment_parts(all_parts) != 0
1945                assert self.check_payment_parts(all_parts) != 0
1946                if debug:
1947                    pp().pprint(positive_parts)
1948                    pp().pprint(all_parts)
1949                # dynamic discount
1950                suite = []
1951                count = 3
1952                for exceed in [False, True]:
1953                    case = []
1954                    for parts in [positive_parts, all_parts]:
1955                        part = parts.copy()
1956                        demand = part['demand']
1957                        if debug:
1958                            print(demand, part['total'])
1959                        i = 0
1960                        z = demand / count
1961                        cp = {
1962                            'account': {},
1963                            'demand': demand,
1964                            'exceed': exceed,
1965                            'total': part['total'],
1966                        }
1967                        j = ''
1968                        for x, y in part['account'].items():
1969                            x_exchange = self.exchange(x)
1970                            zz = self.exchange_calc(z, 1, x_exchange['rate'])
1971                            if exceed and zz <= demand:
1972                                i += 1
1973                                y['part'] = zz
1974                                if debug:
1975                                    print(exceed, y)
1976                                cp['account'][x] = y
1977                                case.append(y)
1978                            elif not exceed and y['balance'] >= zz:
1979                                i += 1
1980                                y['part'] = zz
1981                                if debug:
1982                                    print(exceed, y)
1983                                cp['account'][x] = y
1984                                case.append(y)
1985                            j = x
1986                            if i >= count:
1987                                break
1988                        if len(cp['account'][j]) > 0:
1989                            suite.append(cp)
1990                if debug:
1991                    print('suite', len(suite))
1992                for case in suite:
1993                    if debug:
1994                        print(case)
1995                    result = self.check_payment_parts(case)
1996                    if debug:
1997                        print('check_payment_parts', result, f'exceed: {exceed}')
1998                    assert result == 0
1999
2000                report = self.check(2.17, None, debug)
2001                (valid, brief, plan) = report
2002                if debug:
2003                    print('valid', valid)
2004                assert self.zakat(report, parts=suite, debug=debug)
2005                assert self.save(path + '.pickle')
2006                assert self.export_json(path + '.json')
2007
2008            # exchange
2009
2010            self.exchange("cash", 25, 3.75, "2024-06-25")
2011            self.exchange("cash", 22, 3.73, "2024-06-22")
2012            self.exchange("cash", 15, 3.69, "2024-06-15")
2013            self.exchange("cash", 10, 3.66)
2014
2015            for i in range(1, 30):
2016                rate, description = self.exchange("cash", i).values()
2017                if debug:
2018                    print(i, rate, description)
2019                if i < 10:
2020                    assert rate == 1
2021                    assert description is None
2022                elif i == 10:
2023                    assert rate == 3.66
2024                    assert description is None
2025                elif i < 15:
2026                    assert rate == 3.66
2027                    assert description is None
2028                elif i == 15:
2029                    assert rate == 3.69
2030                    assert description is not None
2031                elif i < 22:
2032                    assert rate == 3.69
2033                    assert description is not None
2034                elif i == 22:
2035                    assert rate == 3.73
2036                    assert description is not None
2037                elif i >= 25:
2038                    assert rate == 3.75
2039                    assert description is not None
2040                rate, description = self.exchange("bank", i).values()
2041                if debug:
2042                    print(i, rate, description)
2043                assert rate == 1
2044                assert description is None
2045
2046            assert len(self._vault['exchange']) > 0
2047            assert len(self.exchanges()) > 0
2048            self._vault['exchange'].clear()
2049            assert len(self._vault['exchange']) == 0
2050            assert len(self.exchanges()) == 0
2051
2052            # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية
2053            self.exchange("cash", ZakatTracker.day_to_time(25), 3.75, "2024-06-25")
2054            self.exchange("cash", ZakatTracker.day_to_time(22), 3.73, "2024-06-22")
2055            self.exchange("cash", ZakatTracker.day_to_time(15), 3.69, "2024-06-15")
2056            self.exchange("cash", ZakatTracker.day_to_time(10), 3.66)
2057
2058            for i in [x * 0.12 for x in range(-15, 21)]:
2059                if i <= 0:
2060                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) == 0
2061                else:
2062                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) > 0
2063
2064            # اختبار النتائج باستخدام التواريخ بالنانو ثانية
2065            for i in range(1, 31):
2066                timestamp_ns = ZakatTracker.day_to_time(i)
2067                rate, description = self.exchange("cash", timestamp_ns).values()
2068                if debug:
2069                    print(i, rate, description)
2070                if i < 10:
2071                    assert rate == 1
2072                    assert description is None
2073                elif i == 10:
2074                    assert rate == 3.66
2075                    assert description is None
2076                elif i < 15:
2077                    assert rate == 3.66
2078                    assert description is None
2079                elif i == 15:
2080                    assert rate == 3.69
2081                    assert description is not None
2082                elif i < 22:
2083                    assert rate == 3.69
2084                    assert description is not None
2085                elif i == 22:
2086                    assert rate == 3.73
2087                    assert description is not None
2088                elif i >= 25:
2089                    assert rate == 3.75
2090                    assert description is not None
2091                rate, description = self.exchange("bank", i).values()
2092                if debug:
2093                    print(i, rate, description)
2094                assert rate == 1
2095                assert description is None
2096
2097            assert self.export_json("1000-transactions-test.json")
2098            assert self.save("1000-transactions-test.pickle")
2099
2100            self.reset()
2101
2102            # test transfer between accounts with different exchange rate
2103
2104            a_SAR = "Bank (SAR)"
2105            b_USD = "Bank (USD)"
2106            c_SAR = "Safe (SAR)"
2107            # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer
2108            for case in [
2109                (0, a_SAR, "SAR Gift", 1000, 1000),
2110                (1, a_SAR, 1),
2111                (0, b_USD, "USD Gift", 500, 500),
2112                (1, b_USD, 1),
2113                (2, b_USD, 3.75),
2114                (1, b_USD, 3.75),
2115                (3, 100, b_USD, a_SAR, "100 USD -> SAR", 400, 1375),
2116                (0, c_SAR, "Salary", 750, 750),
2117                (3, 375, c_SAR, b_USD, "375 SAR -> USD", 375, 500),
2118                (3, 3.75, a_SAR, b_USD, "3.75 SAR -> USD", 1371.25, 501),
2119            ]:
2120                match (case[0]):
2121                    case 0:  # track
2122                        _, account, desc, x, balance = case
2123                        self.track(value=x, desc=desc, account=account, debug=debug)
2124
2125                        cached_value = self.balance(account, cached=True)
2126                        fresh_value = self.balance(account, cached=False)
2127                        if debug:
2128                            print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value)
2129                        assert cached_value == balance
2130                        assert fresh_value == balance
2131                    case 1:  # check-exchange
2132                        _, account, expected_rate = case
2133                        t_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
2134                        if debug:
2135                            print('t-exchange', t_exchange)
2136                        assert t_exchange['rate'] == expected_rate
2137                    case 2:  # do-exchange
2138                        _, account, rate = case
2139                        self.exchange(account, rate=rate, debug=debug)
2140                        b_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
2141                        if debug:
2142                            print('b-exchange', b_exchange)
2143                        assert b_exchange['rate'] == rate
2144                    case 3:  # transfer
2145                        _, x, a, b, desc, a_balance, b_balance = case
2146                        self.transfer(x, a, b, desc, debug=debug)
2147
2148                        cached_value = self.balance(a, cached=True)
2149                        fresh_value = self.balance(a, cached=False)
2150                        if debug:
2151                            print('account', a, 'cached_value', cached_value, 'fresh_value', fresh_value)
2152                        assert cached_value == a_balance
2153                        assert fresh_value == a_balance
2154
2155                        cached_value = self.balance(b, cached=True)
2156                        fresh_value = self.balance(b, cached=False)
2157                        if debug:
2158                            print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value)
2159                        assert cached_value == b_balance
2160                        assert fresh_value == b_balance
2161
2162            # Transfer all in many chunks randomly from B to A
2163            a_SAR_balance = 1371.25
2164            b_USD_balance = 501
2165            b_USD_exchange = self.exchange(b_USD)
2166            amounts = ZakatTracker.create_random_list(b_USD_balance)
2167            if debug:
2168                print('amounts', amounts)
2169            i = 0
2170            for x in amounts:
2171                if debug:
2172                    print(f'{i} - transfer-with-exchange({x})')
2173                self.transfer(x, b_USD, a_SAR, f"{x} USD -> SAR", debug=debug)
2174
2175                b_USD_balance -= x
2176                cached_value = self.balance(b_USD, cached=True)
2177                fresh_value = self.balance(b_USD, cached=False)
2178                if debug:
2179                    print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
2180                          b_USD_balance)
2181                assert cached_value == b_USD_balance
2182                assert fresh_value == b_USD_balance
2183
2184                a_SAR_balance += x * b_USD_exchange['rate']
2185                cached_value = self.balance(a_SAR, cached=True)
2186                fresh_value = self.balance(a_SAR, cached=False)
2187                if debug:
2188                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
2189                          a_SAR_balance, 'rate', b_USD_exchange['rate'])
2190                assert cached_value == a_SAR_balance
2191                assert fresh_value == a_SAR_balance
2192                i += 1
2193
2194            # Transfer all in many chunks randomly from C to A
2195            c_SAR_balance = 375
2196            amounts = ZakatTracker.create_random_list(c_SAR_balance)
2197            if debug:
2198                print('amounts', amounts)
2199            i = 0
2200            for x in amounts:
2201                if debug:
2202                    print(f'{i} - transfer-with-exchange({x})')
2203                self.transfer(x, c_SAR, a_SAR, f"{x} SAR -> a_SAR", debug=debug)
2204
2205                c_SAR_balance -= x
2206                cached_value = self.balance(c_SAR, cached=True)
2207                fresh_value = self.balance(c_SAR, cached=False)
2208                if debug:
2209                    print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
2210                          c_SAR_balance)
2211                assert cached_value == c_SAR_balance
2212                assert fresh_value == c_SAR_balance
2213
2214                a_SAR_balance += x
2215                cached_value = self.balance(a_SAR, cached=True)
2216                fresh_value = self.balance(a_SAR, cached=False)
2217                if debug:
2218                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
2219                          a_SAR_balance)
2220                assert cached_value == a_SAR_balance
2221                assert fresh_value == a_SAR_balance
2222                i += 1
2223
2224            assert self.export_json("accounts-transfer-with-exchange-rates.json")
2225            assert self.save("accounts-transfer-with-exchange-rates.pickle")
2226
2227            # check & zakat with exchange rates for many cycles
2228
2229            for rate, values in {
2230                1: {
2231                    'in': [1000, 2000, 10000],
2232                    'exchanged': [1000, 2000, 10000],
2233                    'out': [25, 50, 731.40625],
2234                },
2235                3.75: {
2236                    'in': [200, 1000, 5000],
2237                    'exchanged': [750, 3750, 18750],
2238                    'out': [18.75, 93.75, 1371.38671875],
2239                },
2240            }.items():
2241                a, b, c = values['in']
2242                m, n, o = values['exchanged']
2243                x, y, z = values['out']
2244                if debug:
2245                    print('rate', rate, 'values', values)
2246                for case in [
2247                    (a, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
2248                        {'safe': {0: {'below_nisab': x}}},
2249                    ], False, m),
2250                    (b, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
2251                        {'safe': {0: {'count': 1, 'total': y}}},
2252                    ], True, n),
2253                    (c, 'cave', ZakatTracker.time() - (ZakatTracker.TimeCycle() * 3), [
2254                        {'cave': {0: {'count': 3, 'total': z}}},
2255                    ], True, o),
2256                ]:
2257                    if debug:
2258                        print(f"############# check(rate: {rate}) #############")
2259                    self.reset()
2260                    self.exchange(account=case[1], created=case[2], rate=rate)
2261                    self.track(value=case[0], desc='test-check', account=case[1], logging=True, created=case[2])
2262
2263                    # assert self.nolock()
2264                    # history_size = len(self._vault['history'])
2265                    # print('history_size', history_size)
2266                    # assert history_size == 2
2267                    assert self.lock()
2268                    assert not self.nolock()
2269                    report = self.check(2.17, None, debug)
2270                    (valid, brief, plan) = report
2271                    assert valid == case[4]
2272                    if debug:
2273                        print('brief', brief)
2274                    assert case[5] == brief[0]
2275                    assert case[5] == brief[1]
2276
2277                    if debug:
2278                        pp().pprint(plan)
2279
2280                    for x in plan:
2281                        assert case[1] == x
2282                        if 'total' in case[3][0][x][0].keys():
2283                            assert case[3][0][x][0]['total'] == brief[2]
2284                            assert plan[x][0]['total'] == case[3][0][x][0]['total']
2285                            assert plan[x][0]['count'] == case[3][0][x][0]['count']
2286                        else:
2287                            assert plan[x][0]['below_nisab'] == case[3][0][x][0]['below_nisab']
2288                    if debug:
2289                        pp().pprint(report)
2290                    result = self.zakat(report, debug=debug)
2291                    if debug:
2292                        print('zakat-result', result, case[4])
2293                    assert result == case[4]
2294                    report = self.check(2.17, None, debug)
2295                    (valid, brief, plan) = report
2296                    assert valid is False
2297
2298            history_size = len(self._vault['history'])
2299            if debug:
2300                print('history_size', history_size)
2301            assert history_size == 3
2302            assert not self.nolock()
2303            assert self.recall(False, debug) is False
2304            self.free(self.lock())
2305            assert self.nolock()
2306            for i in range(3, 0, -1):
2307                history_size = len(self._vault['history'])
2308                if debug:
2309                    print('history_size', history_size)
2310                assert history_size == i
2311                assert self.recall(False, debug) is True
2312
2313            assert self.nolock()
2314
2315            assert self.recall(False, debug) is False
2316            history_size = len(self._vault['history'])
2317            if debug:
2318                print('history_size', history_size)
2319            assert history_size == 0
2320
2321            assert len(self._vault['account']) == 0
2322            assert len(self._vault['history']) == 0
2323            assert len(self._vault['report']) == 0
2324            assert self.nolock()
2325            return True
2326        except:
2327            # pp().pprint(self._vault)
2328            assert self.export_json("test-snapshot.json")
2329            assert self.save("test-snapshot.pickle")
2330            raise
class Action(enum.Enum):
70class Action(Enum):
71    CREATE = auto()
72    TRACK = auto()
73    LOG = auto()
74    SUB = auto()
75    ADD_FILE = auto()
76    REMOVE_FILE = auto()
77    BOX_TRANSFER = auto()
78    EXCHANGE = auto()
79    REPORT = auto()
80    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):
83class JSONEncoder(json.JSONEncoder):
84    def default(self, obj):
85        if isinstance(obj, Action) or isinstance(obj, MathOperation):
86            return obj.name  # Serialize as the enum member's name
87        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):
84    def default(self, obj):
85        if isinstance(obj, Action) or isinstance(obj, MathOperation):
86            return obj.name  # Serialize as the enum member's name
87        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):
90class MathOperation(Enum):
91    ADDITION = auto()
92    EQUAL = auto()
93    SUBTRACTION = auto()
ADDITION = <MathOperation.ADDITION: 1>
EQUAL = <MathOperation.EQUAL: 2>
SUBTRACTION = <MathOperation.SUBTRACTION: 3>