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

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:
230    @staticmethod
231    def time(now: datetime = None) -> int:
232        """
233        Generates a timestamp based on the provided datetime object or the current datetime.
234
235        Parameters:
236        now (datetime, optional): The datetime object to generate the timestamp from.
237        If not provided, the current datetime is used.
238
239        Returns:
240        int: The timestamp in positive nanoseconds since the Unix epoch (January 1, 1970),
241            before 1970 will return in negative until 1000AD.
242        """
243        if now is None:
244            now = datetime.datetime.now()
245        ordinal_day = now.toordinal()
246        ns_in_day = (now - now.replace(hour=0, minute=0, second=0, microsecond=0)).total_seconds() * 10 ** 9
247        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'>:
249    @staticmethod
250    def time_to_datetime(ordinal_ns: int) -> datetime:
251        """
252        Converts an ordinal number (number of days since 1000-01-01) to a datetime object.
253
254        Parameters:
255        ordinal_ns (int): The ordinal number of days since 1000-01-01.
256
257        Returns:
258        datetime: The corresponding datetime object.
259        """
260        ordinal_day = ordinal_ns // 86_400_000_000_000 + 719_163
261        ns_in_day = ordinal_ns % 86_400_000_000_000
262        d = datetime.datetime.fromordinal(ordinal_day)
263        t = datetime.timedelta(seconds=ns_in_day // 10 ** 9)
264        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:
302    def nolock(self) -> bool:
303        """
304        Check if the vault lock is currently not set.
305
306        :return: True if the vault lock is not set, False otherwise.
307        """
308        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:
310    def lock(self) -> int:
311        """
312        Acquires a lock on the ZakatTracker instance.
313
314        Returns:
315        int: The lock ID. This ID can be used to release the lock later.
316        """
317        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:
319    def box(self) -> dict:
320        """
321        Returns a copy of the internal vault dictionary.
322
323        This method is used to retrieve the current state of the ZakatTracker object.
324        It provides a snapshot of the internal data structure, allowing for further
325        processing or analysis.
326
327        :return: A copy of the internal vault dictionary.
328        """
329        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:
331    def steps(self) -> dict:
332        """
333        Returns a copy of the history of steps taken in the ZakatTracker.
334
335        The history is a dictionary where each key is a unique identifier for a step,
336        and the corresponding value is a dictionary containing information about the step.
337
338        :return: A copy of the history of steps taken in the ZakatTracker.
339        """
340        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:
342    def free(self, lock: int, auto_save: bool = True) -> bool:
343        """
344        Releases the lock on the database.
345
346        Parameters:
347        lock (int): The lock ID to be released.
348        auto_save (bool): Whether to automatically save the database after releasing the lock.
349
350        Returns:
351        bool: True if the lock is successfully released and (optionally) saved, False otherwise.
352        """
353        if lock == self._vault['lock']:
354            self._vault['lock'] = None
355            if auto_save:
356                return self.save(self.path())
357            return True
358        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:
360    def account_exists(self, account) -> bool:
361        """
362        Check if the given account exists in the vault.
363
364        Parameters:
365        account (str): The account number to check.
366
367        Returns:
368        bool: True if the account exists, False otherwise.
369        """
370        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:
372    def box_size(self, account) -> int:
373        """
374        Calculate the size of the box for a specific account.
375
376        Parameters:
377        account (str): The account number for which the box size needs to be calculated.
378
379        Returns:
380        int: The size of the box for the given account. If the account does not exist, -1 is returned.
381        """
382        if self.account_exists(account):
383            return len(self._vault['account'][account]['box'])
384        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:
386    def log_size(self, account) -> int:
387        """
388        Get the size of the log for a specific account.
389
390        Parameters:
391        account (str): The account number for which the log size needs to be calculated.
392
393        Returns:
394        int: The size of the log for the given account. If the account does not exist, -1 is returned.
395        """
396        if self.account_exists(account):
397            return len(self._vault['account'][account]['log'])
398        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:
400    def recall(self, dry=True, debug=False) -> bool:
401        """
402        Revert the last operation.
403
404        Parameters:
405        dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
406        debug (bool): If True, the function will print debug information. Default is False.
407
408        Returns:
409        bool: True if the operation was successful, False otherwise.
410        """
411        if not self.nolock() or len(self._vault['history']) == 0:
412            return False
413        if len(self._vault['history']) <= 0:
414            return False
415        ref = sorted(self._vault['history'].keys())[-1]
416        if debug:
417            print('recall', ref)
418        memory = self._vault['history'][ref]
419        if debug:
420            print(type(memory), 'memory', memory)
421
422        limit = len(memory) + 1
423        sub_positive_log_negative = 0
424        for i in range(-1, -limit, -1):
425            x = memory[i]
426            if debug:
427                print(type(x), x)
428            match x['action']:
429                case Action.CREATE:
430                    if x['account'] is not None:
431                        if self.account_exists(x['account']):
432                            if debug:
433                                print('account', self._vault['account'][x['account']])
434                            assert len(self._vault['account'][x['account']]['box']) == 0
435                            assert self._vault['account'][x['account']]['balance'] == 0
436                            assert self._vault['account'][x['account']]['count'] == 0
437                            if dry:
438                                continue
439                            del self._vault['account'][x['account']]
440
441                case Action.TRACK:
442                    if x['account'] is not None:
443                        if self.account_exists(x['account']):
444                            if dry:
445                                continue
446                            self._vault['account'][x['account']]['balance'] -= x['value']
447                            self._vault['account'][x['account']]['count'] -= 1
448                            del self._vault['account'][x['account']]['box'][x['ref']]
449
450                case Action.LOG:
451                    if x['account'] is not None:
452                        if self.account_exists(x['account']):
453                            if x['ref'] in self._vault['account'][x['account']]['log']:
454                                if dry:
455                                    continue
456                                if sub_positive_log_negative == -x['value']:
457                                    self._vault['account'][x['account']]['count'] -= 1
458                                    sub_positive_log_negative = 0
459                                del self._vault['account'][x['account']]['log'][x['ref']]
460
461                case Action.SUB:
462                    if x['account'] is not None:
463                        if self.account_exists(x['account']):
464                            if x['ref'] in self._vault['account'][x['account']]['box']:
465                                if dry:
466                                    continue
467                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] += x['value']
468                                self._vault['account'][x['account']]['balance'] += x['value']
469                                sub_positive_log_negative = x['value']
470
471                case Action.ADD_FILE:
472                    if x['account'] is not None:
473                        if self.account_exists(x['account']):
474                            if x['ref'] in self._vault['account'][x['account']]['log']:
475                                if x['file'] in self._vault['account'][x['account']]['log'][x['ref']]['file']:
476                                    if dry:
477                                        continue
478                                    del self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']]
479
480                case Action.REMOVE_FILE:
481                    if x['account'] is not None:
482                        if self.account_exists(x['account']):
483                            if x['ref'] in self._vault['account'][x['account']]['log']:
484                                if dry:
485                                    continue
486                                self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']] = x['value']
487
488                case Action.BOX_TRANSFER:
489                    if x['account'] is not None:
490                        if self.account_exists(x['account']):
491                            if x['ref'] in self._vault['account'][x['account']]['box']:
492                                if dry:
493                                    continue
494                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] -= x['value']
495
496                case Action.EXCHANGE:
497                    if x['account'] is not None:
498                        if x['account'] in self._vault['exchange']:
499                            if x['ref'] in self._vault['exchange'][x['account']]:
500                                if dry:
501                                    continue
502                                del self._vault['exchange'][x['account']][x['ref']]
503
504                case Action.REPORT:
505                    if x['ref'] in self._vault['report']:
506                        if dry:
507                            continue
508                        del self._vault['report'][x['ref']]
509
510                case Action.ZAKAT:
511                    if x['account'] is not None:
512                        if self.account_exists(x['account']):
513                            if x['ref'] in self._vault['account'][x['account']]['box']:
514                                if x['key'] in self._vault['account'][x['account']]['box'][x['ref']]:
515                                    if dry:
516                                        continue
517                                    match x['math']:
518                                        case MathOperation.ADDITION:
519                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] -= x[
520                                                'value']
521                                        case MathOperation.EQUAL:
522                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] = x['value']
523                                        case MathOperation.SUBTRACTION:
524                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] += x[
525                                                'value']
526
527        if not dry:
528            del self._vault['history'][ref]
529        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:
531    def ref_exists(self, account: str, ref_type: str, ref: int) -> bool:
532        """
533        Check if a specific reference (transaction) exists in the vault for a given account and reference type.
534
535        Parameters:
536        account (str): The account number for which to check the existence of the reference.
537        ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
538        ref (int): The reference (transaction) number to check for existence.
539
540        Returns:
541        bool: True if the reference exists for the given account and reference type, False otherwise.
542        """
543        if account in self._vault['account']:
544            return ref in self._vault['account'][account][ref_type]
545        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:
547    def box_exists(self, account: str, ref: int) -> bool:
548        """
549        Check if a specific box (transaction) exists in the vault for a given account and reference.
550
551        Parameters:
552        - account (str): The account number for which to check the existence of the box.
553        - ref (int): The reference (transaction) number to check for existence.
554
555        Returns:
556        - bool: True if the box exists for the given account and reference, False otherwise.
557        """
558        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:
560    def track(self, value: float = 0, desc: str = '', account: str = 1, logging: bool = True, created: int = None,
561              debug: bool = False) -> int:
562        """
563        This function tracks a transaction for a specific account.
564
565        Parameters:
566        value (float): The value of the transaction. Default is 0.
567        desc (str): The description of the transaction. Default is an empty string.
568        account (str): The account for which the transaction is being tracked. Default is '1'.
569        logging (bool): Whether to log the transaction. Default is True.
570        created (int): The timestamp of the transaction. If not provided, it will be generated. Default is None.
571        debug (bool): Whether to print debug information. Default is False.
572
573        Returns:
574        int: The timestamp of the transaction.
575
576        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.
577
578        Raises:
579        ValueError: The log transaction happened again in the same nanosecond time.
580        ValueError: The box transaction happened again in the same nanosecond time.
581        """
582        if created is None:
583            created = self.time()
584        no_lock = self.nolock()
585        self.lock()
586        if not self.account_exists(account):
587            if debug:
588                print(f"account {account} created")
589            self._vault['account'][account] = {
590                'balance': 0,
591                'box': {},
592                'count': 0,
593                'log': {},
594                'zakatable': True,
595            }
596            self._step(Action.CREATE, account)
597        if value == 0:
598            if no_lock:
599                self.free(self.lock())
600            return 0
601        if logging:
602            self._log(value, desc, account, created, debug)
603        if debug:
604            print('create-box', created)
605        if self.box_exists(account, created):
606            raise ValueError(f"The box transaction happened again in the same nanosecond time({created}).")
607        if debug:
608            print('created-box', created)
609        self._vault['account'][account]['box'][created] = {
610            'capital': value,
611            'count': 0,
612            'last': 0,
613            'rest': value,
614            'total': 0,
615        }
616        self._step(Action.TRACK, account, ref=created, value=value)
617        if no_lock:
618            self.free(self.lock())
619        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:
621    def log_exists(self, account: str, ref: int) -> bool:
622        """
623        Checks if a specific transaction log entry exists for a given account.
624
625        Parameters:
626        account (str): The account number associated with the transaction log.
627        ref (int): The reference to the transaction log entry.
628
629        Returns:
630        bool: True if the transaction log entry exists, False otherwise.
631        """
632        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:
671    def exchange(self, account, created: int = None, rate: float = None, description: str = None,
672                 debug: bool = False) -> dict:
673        """
674        This method is used to record or retrieve exchange rates for a specific account.
675
676        Parameters:
677        - account (str): The account number for which the exchange rate is being recorded or retrieved.
678        - created (int): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
679        - rate (float): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
680        - description (str): A description of the exchange rate.
681
682        Returns:
683        - dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found,
684        it returns a dictionary with default values for the rate and description.
685        """
686        if created is None:
687            created = self.time()
688        no_lock = self.nolock()
689        self.lock()
690        if rate is not None:
691            if rate <= 0:
692                return dict()
693            if account not in self._vault['exchange']:
694                self._vault['exchange'][account] = {}
695            if len(self._vault['exchange'][account]) == 0 and rate <= 1:
696                return {"rate": 1, "description": None}
697            self._vault['exchange'][account][created] = {"rate": rate, "description": description}
698            self._step(Action.EXCHANGE, account, ref=created, value=rate)
699            if no_lock:
700                self.free(self.lock())
701            if debug:
702                print("exchange-created-1",
703                      f'account: {account}, created: {created}, rate:{rate}, description:{description}')
704
705        if account in self._vault['exchange']:
706            valid_rates = [(ts, r) for ts, r in self._vault['exchange'][account].items() if ts <= created]
707            if valid_rates:
708                latest_rate = max(valid_rates, key=lambda x: x[0])
709                if debug:
710                    print("exchange-read-1",
711                          f'account: {account}, created: {created}, rate:{rate}, description:{description}',
712                          'latest_rate', latest_rate)
713                return latest_rate[1]  # إرجاع قاموس يحتوي على المعدل والوصف
714        if debug:
715            print("exchange-read-0", f'account: {account}, created: {created}, rate:{rate}, description:{description}')
716        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:
718    @staticmethod
719    def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
720        """
721        This function calculates the exchanged amount of a currency.
722
723        Args:
724            x (float): The original amount of the currency.
725            x_rate (float): The exchange rate of the original currency.
726            y_rate (float): The exchange rate of the target currency.
727
728        Returns:
729            float: The exchanged amount of the target currency.
730        """
731        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:
733    def exchanges(self) -> dict:
734        """
735        Retrieve the recorded exchange rates for all accounts.
736
737        Parameters:
738        None
739
740        Returns:
741        dict: A dictionary containing all recorded exchange rates.
742        The keys are account names or numbers, and the values are dictionaries containing the exchange rates.
743        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
744        """
745        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:
747    def accounts(self) -> dict:
748        """
749        Returns a dictionary containing account numbers as keys and their respective balances as values.
750
751        Parameters:
752        None
753
754        Returns:
755        dict: A dictionary where keys are account numbers and values are their respective balances.
756        """
757        result = {}
758        for i in self._vault['account']:
759            result[i] = self._vault['account'][i]['balance']
760        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:
762    def boxes(self, account) -> dict:
763        """
764        Retrieve the boxes (transactions) associated with a specific account.
765
766        Parameters:
767        account (str): The account number for which to retrieve the boxes.
768
769        Returns:
770        dict: A dictionary containing the boxes associated with the given account.
771        If the account does not exist, an empty dictionary is returned.
772        """
773        if self.account_exists(account):
774            return self._vault['account'][account]['box']
775        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:
777    def logs(self, account) -> dict:
778        """
779        Retrieve the logs (transactions) associated with a specific account.
780
781        Parameters:
782        account (str): The account number for which to retrieve the logs.
783
784        Returns:
785        dict: A dictionary containing the logs associated with the given account.
786        If the account does not exist, an empty dictionary is returned.
787        """
788        if self.account_exists(account):
789            return self._vault['account'][account]['log']
790        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:
792    def add_file(self, account: str, ref: int, path: str) -> int:
793        """
794        Adds a file reference to a specific transaction log entry in the vault.
795
796        Parameters:
797        account (str): The account number associated with the transaction log.
798        ref (int): The reference to the transaction log entry.
799        path (str): The path of the file to be added.
800
801        Returns:
802        int: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
803        """
804        if self.account_exists(account):
805            if ref in self._vault['account'][account]['log']:
806                file_ref = self.time()
807                self._vault['account'][account]['log'][ref]['file'][file_ref] = path
808                no_lock = self.nolock()
809                self.lock()
810                self._step(Action.ADD_FILE, account, ref=ref, file=file_ref)
811                if no_lock:
812                    self.free(self.lock())
813                return file_ref
814        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:
816    def remove_file(self, account: str, ref: int, file_ref: int) -> bool:
817        """
818        Removes a file reference from a specific transaction log entry in the vault.
819
820        Parameters:
821        account (str): The account number associated with the transaction log.
822        ref (int): The reference to the transaction log entry.
823        file_ref (int): The reference of the file to be removed.
824
825        Returns:
826        bool: True if the file reference is successfully removed, False otherwise.
827        """
828        if self.account_exists(account):
829            if ref in self._vault['account'][account]['log']:
830                if file_ref in self._vault['account'][account]['log'][ref]['file']:
831                    x = self._vault['account'][account]['log'][ref]['file'][file_ref]
832                    del self._vault['account'][account]['log'][ref]['file'][file_ref]
833                    no_lock = self.nolock()
834                    self.lock()
835                    self._step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x)
836                    if no_lock:
837                        self.free(self.lock())
838                    return True
839        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:
841    def balance(self, account: str = 1, cached: bool = True) -> int:
842        """
843        Calculate and return the balance of a specific account.
844
845        Parameters:
846        account (str): The account number. Default is '1'.
847        cached (bool): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
848
849        Returns:
850        int: The balance of the account.
851
852        Note:
853        If cached is True, the function returns the cached balance.
854        If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
855        """
856        if cached:
857            return self._vault['account'][account]['balance']
858        x = 0
859        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:
861    def zakatable(self, account, status: bool = None) -> bool:
862        """
863        Check or set the zakatable status of a specific account.
864
865        Parameters:
866        account (str): The account number.
867        status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
868
869        Returns:
870        bool: The current or updated zakatable status of the account.
871
872        Raises:
873        None
874
875        Example:
876        >>> tracker = ZakatTracker()
877        >>> ref = tracker.track(51, 'desc', 'account1')
878        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
879        True
880        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
881        True
882        >>> tracker.zakatable('account1', False)
883        False
884        """
885        if self.account_exists(account):
886            if status is None:
887                return self._vault['account'][account]['zakatable']
888            self._vault['account'][account]['zakatable'] = status
889            return status
890        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:
892    def sub(self, x: float, desc: str = '', account: str = 1, created: int = None, debug: bool = False) -> tuple:
893        """
894        Subtracts a specified value from an account's balance.
895
896        Parameters:
897        x (float): The amount to be subtracted.
898        desc (str): A description for the transaction. Defaults to an empty string.
899        account (str): The account from which the value will be subtracted. Defaults to '1'.
900        created (int): The timestamp of the transaction. If not provided, the current timestamp will be used.
901        debug (bool): A flag indicating whether to print debug information. Defaults to False.
902
903        Returns:
904        tuple: A tuple containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
905
906        If the amount to subtract is greater than the account's balance,
907        the remaining amount will be transferred to a new transaction with a negative value.
908
909        Raises:
910        ValueError: The box transaction happened again in the same nanosecond time.
911        ValueError: The log transaction happened again in the same nanosecond time.
912        """
913        if x < 0:
914            return tuple()
915        if x == 0:
916            ref = self.track(x, '', account)
917            return ref, ref
918        if created is None:
919            created = self.time()
920        no_lock = self.nolock()
921        self.lock()
922        self.track(0, '', account)
923        self._log(-x, desc, account, created)
924        ids = sorted(self._vault['account'][account]['box'].keys())
925        limit = len(ids) + 1
926        target = x
927        if debug:
928            print('ids', ids)
929        ages = []
930        for i in range(-1, -limit, -1):
931            if target == 0:
932                break
933            j = ids[i]
934            if debug:
935                print('i', i, 'j', j)
936            rest = self._vault['account'][account]['box'][j]['rest']
937            if rest >= target:
938                self._vault['account'][account]['box'][j]['rest'] -= target
939                self._step(Action.SUB, account, ref=j, value=target)
940                ages.append((j, target))
941                target = 0
942                break
943            elif rest < target and rest > 0:
944                chunk = rest
945                target -= chunk
946                self._step(Action.SUB, account, ref=j, value=chunk)
947                ages.append((j, chunk))
948                self._vault['account'][account]['box'][j]['rest'] = 0
949        if target > 0:
950            self.track(-target, desc, account, False, created)
951            ages.append((created, target))
952        if no_lock:
953            self.free(self.lock())
954        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]:
 956    def transfer(self, amount: int, from_account: str, to_account: str, desc: str = '', created: int = None,
 957                 debug: bool = False) -> list[int]:
 958        """
 959        Transfers a specified value from one account to another.
 960
 961        Parameters:
 962        amount (int): The amount to be transferred.
 963        from_account (str): The account from which the value will be transferred.
 964        to_account (str): The account to which the value will be transferred.
 965        desc (str, optional): A description for the transaction. Defaults to an empty string.
 966        created (int, optional): The timestamp of the transaction. If not provided, the current timestamp will be used.
 967        debug (bool): A flag indicating whether to print debug information. Defaults to False.
 968
 969        Returns:
 970        list[int]: A list of timestamps corresponding to the transactions made during the transfer.
 971
 972        Raises:
 973        ValueError: The box transaction happened again in the same nanosecond time.
 974        ValueError: The log transaction happened again in the same nanosecond time.
 975        """
 976        if amount <= 0:
 977            return []
 978        if created is None:
 979            created = self.time()
 980        (_, ages) = self.sub(amount, desc, from_account, created, debug=debug)
 981        times = []
 982        source_exchange = self.exchange(from_account, created)
 983        target_exchange = self.exchange(to_account, created)
 984
 985        if debug:
 986            print('ages', ages)
 987
 988        for age, value in ages:
 989            target_amount = self.exchange_calc(value, source_exchange['rate'], target_exchange['rate'])
 990            # Perform the transfer
 991            if self.box_exists(to_account, age):
 992                if debug:
 993                    print('box_exists', age)
 994                capital = self._vault['account'][to_account]['box'][age]['capital']
 995                rest = self._vault['account'][to_account]['box'][age]['rest']
 996                if debug:
 997                    print(
 998                        f"Transfer {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
 999                selected_age = age
1000                if rest + target_amount > capital:
1001                    self._vault['account'][to_account]['box'][age]['capital'] += target_amount
1002                    selected_age = ZakatTracker.time()
1003                self._vault['account'][to_account]['box'][age]['rest'] += target_amount
1004                self._step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount)
1005                y = self._log(target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account,
1006                              debug=debug)
1007                times.append((age, y))
1008                continue
1009            y = self.track(target_amount, desc, to_account, logging=True, created=age, debug=debug)
1010            if debug:
1011                print(
1012                    f"Transferred {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1013            times.append(y)
1014        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:
1016    def check(self, silver_gram_price: float, nisab: float = None, debug: bool = False, now: int = None,
1017              cycle: float = None) -> tuple:
1018        """
1019        Check the eligibility for Zakat based on the given parameters.
1020
1021        Parameters:
1022        silver_gram_price (float): The price of a gram of silver.
1023        nisab (float): The minimum amount of wealth required for Zakat. If not provided,
1024                        it will be calculated based on the silver_gram_price.
1025        debug (bool): Flag to enable debug mode.
1026        now (int): The current timestamp. If not provided, it will be calculated using ZakatTracker.time().
1027        cycle (float): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
1028
1029        Returns:
1030        tuple: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics,
1031        and a dictionary containing the Zakat plan.
1032        """
1033        if now is None:
1034            now = self.time()
1035        if cycle is None:
1036            cycle = ZakatTracker.TimeCycle()
1037        if nisab is None:
1038            nisab = ZakatTracker.Nisab(silver_gram_price)
1039        plan = {}
1040        below_nisab = 0
1041        brief = [0, 0, 0]
1042        valid = False
1043        for x in self._vault['account']:
1044            if not self.zakatable(x):
1045                continue
1046            _box = self._vault['account'][x]['box']
1047            limit = len(_box) + 1
1048            ids = sorted(self._vault['account'][x]['box'].keys())
1049            for i in range(-1, -limit, -1):
1050                j = ids[i]
1051                rest = _box[j]['rest']
1052                if rest <= 0:
1053                    continue
1054                exchange = self.exchange(x, created=j)
1055                if debug:
1056                    print('exchanges', self.exchanges())
1057                rest = ZakatTracker.exchange_calc(rest, exchange['rate'], 1)
1058                brief[0] += rest
1059                index = limit + i - 1
1060                epoch = (now - j) / cycle
1061                if debug:
1062                    print(f"Epoch: {epoch}", _box[j])
1063                if _box[j]['last'] > 0:
1064                    epoch = (now - _box[j]['last']) / cycle
1065                if debug:
1066                    print(f"Epoch: {epoch}")
1067                epoch = floor(epoch)
1068                if debug:
1069                    print(f"Epoch: {epoch}", type(epoch), epoch == 0, 1 - epoch, epoch)
1070                if epoch == 0:
1071                    continue
1072                if debug:
1073                    print("Epoch - PASSED")
1074                brief[1] += rest
1075                if rest >= nisab:
1076                    total = 0
1077                    for _ in range(epoch):
1078                        total += ZakatTracker.ZakatCut(rest - total)
1079                    if total > 0:
1080                        if x not in plan:
1081                            plan[x] = {}
1082                        valid = True
1083                        brief[2] += total
1084                        plan[x][index] = {'total': total, 'count': epoch}
1085                else:
1086                    chunk = ZakatTracker.ZakatCut(rest)
1087                    if chunk > 0:
1088                        if x not in plan:
1089                            plan[x] = {}
1090                        if j not in plan[x].keys():
1091                            plan[x][index] = {}
1092                        below_nisab += rest
1093                        brief[2] += chunk
1094                        plan[x][index]['below_nisab'] = chunk
1095        valid = valid or below_nisab >= nisab
1096        if debug:
1097            print(f"below_nisab({below_nisab}) >= nisab({nisab})")
1098        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:
1100    def build_payment_parts(self, demand: float, positive_only: bool = True) -> dict:
1101        """
1102        Build payment parts for the zakat distribution.
1103
1104        Parameters:
1105        demand (float): The total demand for payment in local currency.
1106        positive_only (bool): If True, only consider accounts with positive balance. Default is True.
1107
1108        Returns:
1109        dict: A dictionary containing the payment parts for each account. The dictionary has the following structure:
1110        {
1111            'account': {
1112                'account_id': {'balance': float, 'rate': float, 'part': float},
1113                ...
1114            },
1115            'exceed': bool,
1116            'demand': float,
1117            'total': float,
1118        }
1119        """
1120        total = 0
1121        parts = {
1122            'account': {},
1123            'exceed': False,
1124            'demand': demand,
1125        }
1126        for x, y in self.accounts().items():
1127            if positive_only and y <= 0:
1128                continue
1129            total += y
1130            exchange = self.exchange(x)
1131            parts['account'][x] = {'balance': y, 'rate': exchange['rate'], 'part': 0}
1132        parts['total'] = total
1133        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:
1135    @staticmethod
1136    def check_payment_parts(parts: dict) -> int:
1137        """
1138        Checks the validity of payment parts.
1139
1140        Parameters:
1141        parts (dict): A dictionary containing payment parts information.
1142
1143        Returns:
1144        int: Returns 0 if the payment parts are valid, otherwise returns the error code.
1145
1146        Error Codes:
1147        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
1148        2: 'balance', 'rate' or 'part' key is missing in parts['account'][x].
1149        3: 'part' value in parts['account'][x] is less than or equal to 0.
1150        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
1151        5: 'part' value in parts['account'][x] is less than 0.
1152        6: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
1153        7: The sum of 'part' values in parts['account'] does not match with 'demand' value.
1154        """
1155        for i in ['demand', 'account', 'total', 'exceed']:
1156            if not i in parts:
1157                return 1
1158        exceed = parts['exceed']
1159        for x in parts['account']:
1160            for j in ['balance', 'rate', 'part']:
1161                if not j in parts['account'][x]:
1162                    return 2
1163                if parts['account'][x]['part'] <= 0:
1164                    return 3
1165                if not exceed and parts['account'][x]['balance'] <= 0:
1166                    return 4
1167        demand = parts['demand']
1168        z = 0
1169        for _, y in parts['account'].items():
1170            if y['part'] < 0:
1171                return 5
1172            if not exceed and y['part'] > y['balance']:
1173                return 6
1174            z += ZakatTracker.exchange_calc(y['part'], y['rate'], 1)
1175        if z != demand:
1176            return 7
1177        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:
1179    def zakat(self, report: tuple, parts: dict = None, debug: bool = False) -> bool:
1180        """
1181        Perform Zakat calculation based on the given report and optional parts.
1182
1183        Parameters:
1184        report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan.
1185        parts (dict): A dictionary containing the payment parts for the zakat.
1186        debug (bool): A flag indicating whether to print debug information.
1187
1188        Returns:
1189        bool: True if the zakat calculation is successful, False otherwise.
1190        """
1191        valid, _, plan = report
1192        if not valid:
1193            return valid
1194        parts_exist = parts is not None
1195        if parts_exist:
1196            for part in parts:
1197                if self.check_payment_parts(part) != 0:
1198                    return False
1199        if debug:
1200            print('######### zakat #######')
1201            print('parts_exist', parts_exist)
1202        no_lock = self.nolock()
1203        self.lock()
1204        report_time = self.time()
1205        self._vault['report'][report_time] = report
1206        self._step(Action.REPORT, ref=report_time)
1207        created = self.time()
1208        for x in plan:
1209            if debug:
1210                print(plan[x])
1211                print('-------------')
1212                print(self._vault['account'][x]['box'])
1213            ids = sorted(self._vault['account'][x]['box'].keys())
1214            if debug:
1215                print('plan[x]', plan[x])
1216            for i in plan[x].keys():
1217                j = ids[i]
1218                if debug:
1219                    print('i', i, 'j', j)
1220                self._step(Action.ZAKAT, account=x, ref=j, value=self._vault['account'][x]['box'][j]['last'],
1221                           key='last',
1222                           math_operation=MathOperation.EQUAL)
1223                self._vault['account'][x]['box'][j]['last'] = created
1224                self._vault['account'][x]['box'][j]['total'] += plan[x][i]['total']
1225                self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['total'], key='total',
1226                           math_operation=MathOperation.ADDITION)
1227                self._vault['account'][x]['box'][j]['count'] += plan[x][i]['count']
1228                self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['count'], key='count',
1229                           math_operation=MathOperation.ADDITION)
1230                if not parts_exist:
1231                    self._vault['account'][x]['box'][j]['rest'] -= plan[x][i]['total']
1232                    self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['total'], key='rest',
1233                               math_operation=MathOperation.SUBTRACTION)
1234        if parts_exist:
1235            for transaction in parts:
1236                for account, part in transaction['account'].items():
1237                    if debug:
1238                        print('zakat-part', account, part['part'])
1239                    target_exchange = self.exchange(account)
1240                    amount = ZakatTracker.exchange_calc(part['part'], part['rate'], target_exchange['rate'])
1241                    self.sub(amount, desc='zakat-part', account=account, debug=debug)
1242        if no_lock:
1243            self.free(self.lock())
1244        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:
1246    def export_json(self, path: str = "data.json") -> bool:
1247        """
1248        Exports the current state of the ZakatTracker object to a JSON file.
1249
1250        Parameters:
1251        path (str): The path where the JSON file will be saved. Default is "data.json".
1252
1253        Returns:
1254        bool: True if the export is successful, False otherwise.
1255
1256        Raises:
1257        No specific exceptions are raised by this method.
1258        """
1259        with open(path, "w") as file:
1260            json.dump(self._vault, file, indent=4, cls=JSONEncoder)
1261            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:
1263    def save(self, path: str = None) -> bool:
1264        """
1265        Save the current state of the ZakatTracker object to a pickle file.
1266
1267        Parameters:
1268        path (str): The path where the pickle file will be saved. If not provided, it will use the default path.
1269
1270        Returns:
1271        bool: True if the save operation is successful, False otherwise.
1272        """
1273        if path is None:
1274            path = self.path()
1275        with open(path, "wb") as f:
1276            pickle.dump(self._vault, f)
1277            return True

Save the current state of the ZakatTracker object to a pickle file.

Parameters: path (str): The path where the pickle file will be saved. If not provided, it will use the default path.

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

def load(self, path: str = None) -> bool:
1279    def load(self, path: str = None) -> bool:
1280        """
1281        Load the current state of the ZakatTracker object from a pickle file.
1282
1283        Parameters:
1284        path (str): The path where the pickle file is located. If not provided, it will use the default path.
1285
1286        Returns:
1287        bool: True if the load operation is successful, False otherwise.
1288        """
1289        if path is None:
1290            path = self.path()
1291        if os.path.exists(path):
1292            with open(path, "rb") as f:
1293                self._vault = pickle.load(f)
1294                return True
1295        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):
1297    def import_csv_cache_path(self):
1298        """
1299        Generates the cache file path for imported CSV data.
1300
1301        This function constructs the file path where cached data from CSV imports
1302        will be stored. The cache file is a pickle file (.pickle extension) appended
1303        to the base path of the object.
1304
1305        Returns:
1306            str: The full path to the import CSV cache file.
1307
1308        Example:
1309            >>> obj = ZakatTracker('/data/reports')
1310            >>> obj.import_csv_cache_path()
1311            '/data/reports.import_csv.pickle'
1312        """
1313        path = self.path()
1314        if path.endswith(".pickle"):
1315            path = path[:-7]
1316        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:
1318    def import_csv(self, path: str = 'file.csv', debug: bool = False) -> tuple:
1319        """
1320        The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system.
1321
1322        Parameters:
1323        path (str): The path to the CSV file. Default is 'file.csv'.
1324        debug (bool): A flag indicating whether to print debug information.
1325
1326        Returns:
1327        tuple: A tuple containing the number of transactions created, the number of transactions found in the cache,
1328                and a dictionary of bad transactions.
1329
1330        Notes:
1331            * Currency Pair Assumption: This function assumes that the exchange rates stored for each account
1332                                        are appropriate for the currency pairs involved in the conversions.
1333            * The exchange rate for each account is based on the last encountered transaction rate that is not equal
1334                to 1.0 or the previous rate for that account.
1335            * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent
1336              transactions of the same account within the whole imported and existing dataset when doing `check` and
1337              `zakat` operations.
1338
1339        Example Usage:
1340            The CSV file should have the following format, rate is optional per transaction:
1341            account, desc, value, date, rate
1342            For example:
1343            safe-45, "Some text", 34872, 1988-06-30 00:00:00, 1
1344        """
1345        cache: list[int] = []
1346        try:
1347            with open(self.import_csv_cache_path(), "rb") as f:
1348                cache = pickle.load(f)
1349        except:
1350            pass
1351        date_formats = [
1352            "%Y-%m-%d %H:%M:%S",
1353            "%Y-%m-%dT%H:%M:%S",
1354            "%Y-%m-%dT%H%M%S",
1355            "%Y-%m-%d",
1356        ]
1357        created, found, bad = 0, 0, {}
1358        data: list[tuple] = []
1359        with open(path, newline='', encoding="utf-8") as f:
1360            i = 0
1361            for row in csv.reader(f, delimiter=','):
1362                i += 1
1363                hashed = hash(tuple(row))
1364                if hashed in cache:
1365                    found += 1
1366                    continue
1367                account = row[0]
1368                desc = row[1]
1369                value = float(row[2])
1370                rate = 1.0
1371                if row[4:5]:  # Empty list if index is out of range
1372                    rate = float(row[4])
1373                date: int = 0
1374                for time_format in date_formats:
1375                    try:
1376                        date = self.time(datetime.datetime.strptime(row[3], time_format))
1377                        break
1378                    except:
1379                        pass
1380                # TODO: not allowed for negative dates
1381                if date == 0 or value == 0:
1382                    bad[i] = row
1383                    continue
1384                if date in data:
1385                    print('import_csv-duplicated(time)', date)
1386                    continue
1387                data.append((date, value, desc, account, rate, hashed))
1388
1389        if debug:
1390            print('import_csv', len(data))
1391        for row in sorted(data, key=lambda x: x[0]):
1392            (date, value, desc, account, rate, hashed) = row
1393            if rate > 1:
1394                self.exchange(account, created=date, rate=rate)
1395            if value > 0:
1396                self.track(value, desc, account, True, date)
1397            elif value < 0:
1398                self.sub(-value, desc, account, date)
1399            created += 1
1400            cache.append(hashed)
1401        with open(self.import_csv_cache_path(), "wb") as f:
1402            pickle.dump(cache, f)
1403        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:
1409    @staticmethod
1410    def duration_from_nanoseconds(ns: int) -> tuple:
1411        """
1412        REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
1413        Convert NanoSeconds to Human Readable Time Format.
1414        A NanoSeconds is a unit of time in the International System of Units (SI) equal
1415        to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
1416        Its symbol is μs, sometimes simplified to us when Unicode is not available.
1417        A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
1418
1419        INPUT : ms (AKA: MilliSeconds)
1420        OUTPUT: tuple(string time_lapsed, string spoken_time) like format.
1421        OUTPUT Variables: time_lapsed, spoken_time
1422
1423        Example  Input: duration_from_nanoseconds(ns)
1424        **"Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds"**
1425        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')
1426        duration_from_nanoseconds(1234567890123456789012)
1427        """
1428        us, ns = divmod(ns, 1000)
1429        ms, us = divmod(us, 1000)
1430        s, ms = divmod(ms, 1000)
1431        m, s = divmod(s, 60)
1432        h, m = divmod(m, 60)
1433        d, h = divmod(h, 24)
1434        y, d = divmod(d, 365)
1435        c, y = divmod(y, 100)
1436        n, c = divmod(c, 10)
1437        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}"
1438        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"
1439        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:
1441    @staticmethod
1442    def day_to_time(day: int, month: int = 6, year: int = 2024) -> int:  # افتراض أن الشهر هو يونيو والسنة 2024
1443        """
1444        Convert a specific day, month, and year into a timestamp.
1445
1446        Parameters:
1447        day (int): The day of the month.
1448        month (int): The month of the year. Default is 6 (June).
1449        year (int): The year. Default is 2024.
1450
1451        Returns:
1452        int: The timestamp representing the given day, month, and year.
1453
1454        Note:
1455        This method assumes the default month and year if not provided.
1456        """
1457        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:
1459    @staticmethod
1460    def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
1461        """
1462        Generate a random date between two given dates.
1463
1464        Parameters:
1465        start_date (datetime.datetime): The start date from which to generate a random date.
1466        end_date (datetime.datetime): The end date until which to generate a random date.
1467
1468        Returns:
1469        datetime.datetime: A random date between the start_date and end_date.
1470        """
1471        time_between_dates = end_date - start_date
1472        days_between_dates = time_between_dates.days
1473        random_number_of_days = random.randrange(days_between_dates)
1474        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) -> None:
1476    @staticmethod
1477    def generate_random_csv_file(path: str = "data.csv", count: int = 1000, with_rate: bool = False) -> None:
1478        """
1479        Generate a random CSV file with specified parameters.
1480
1481        Parameters:
1482        path (str): The path where the CSV file will be saved. Default is "data.csv".
1483        count (int): The number of rows to generate in the CSV file. Default is 1000.
1484        with_rate (bool): If True, a random rate between 1.2% and 12% is added. Default is False.
1485
1486        Returns:
1487        None. The function generates a CSV file at the specified path with the given count of rows.
1488        Each row contains a randomly generated account, description, value, and date.
1489        The value is randomly generated between 1000 and 100000,
1490        and the date is randomly generated between 1950-01-01 and 2023-12-31.
1491        If the row number is not divisible by 13, the value is multiplied by -1.
1492        """
1493        with open(path, "w", newline="") as csvfile:
1494            writer = csv.writer(csvfile)
1495            for i in range(count):
1496                account = f"acc-{random.randint(1, 1000)}"
1497                desc = f"Some text {random.randint(1, 1000)}"
1498                value = random.randint(1000, 100000)
1499                date = ZakatTracker.generate_random_date(datetime.datetime(1950, 1, 1),
1500                                                         datetime.datetime(2023, 12, 31)).strftime("%Y-%m-%d %H:%M:%S")
1501                if not i % 13 == 0:
1502                    value *= -1
1503                row = [account, desc, value, date]
1504                if with_rate:
1505                    rate = random.randint(1,100) * 0.12
1506                    row.append(rate)
1507                writer.writerow(row)

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.

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):
1509    @staticmethod
1510    def create_random_list(max_sum, min_value=0, max_value=10):
1511        """
1512        Creates a list of random integers whose sum does not exceed the specified maximum.
1513
1514        Args:
1515            max_sum: The maximum allowed sum of the list elements.
1516            min_value: The minimum possible value for an element (inclusive).
1517            max_value: The maximum possible value for an element (inclusive).
1518
1519        Returns:
1520            A list of random integers.
1521        """
1522        result = []
1523        current_sum = 0
1524
1525        while current_sum < max_sum:
1526            # Calculate the remaining space for the next element
1527            remaining_sum = max_sum - current_sum
1528            # Determine the maximum possible value for the next element
1529            next_max_value = min(remaining_sum, max_value)
1530            # Generate a random element within the allowed range
1531            next_element = random.randint(min_value, next_max_value)
1532            result.append(next_element)
1533            current_sum += next_element
1534
1535        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:
1675    def test(self, debug: bool = False) -> bool:
1676
1677        try:
1678
1679            assert self._history()
1680
1681            # Not allowed for duplicate transactions in the same account and time
1682
1683            created = ZakatTracker.time()
1684            self.track(100, 'test-1', 'same', True, created)
1685            failed = False
1686            try:
1687                self.track(50, 'test-1', 'same', True, created)
1688            except:
1689                failed = True
1690            assert failed is True
1691
1692            self.reset()
1693
1694            # Always preserve box age during transfer
1695
1696            series: list[tuple] = [
1697                (30, 4),
1698                (60, 3),
1699                (90, 2),
1700            ]
1701            case = {
1702                30: {
1703                    'series': series,
1704                    'rest': 150,
1705                },
1706                60: {
1707                    'series': series,
1708                    'rest': 120,
1709                },
1710                90: {
1711                    'series': series,
1712                    'rest': 90,
1713                },
1714                180: {
1715                    'series': series,
1716                    'rest': 0,
1717                },
1718                270: {
1719                    'series': series,
1720                    'rest': -90,
1721                },
1722                360: {
1723                    'series': series,
1724                    'rest': -180,
1725                },
1726            }
1727
1728            selected_time = ZakatTracker.time() - ZakatTracker.TimeCycle()
1729
1730            for total in case:
1731                for x in case[total]['series']:
1732                    self.track(x[0], f"test-{x} ages", 'ages', True, selected_time * x[1])
1733
1734                refs = self.transfer(total, 'ages', 'future', 'Zakat Movement', debug=debug)
1735
1736                if debug:
1737                    print('refs', refs)
1738
1739                ages_cache_balance = self.balance('ages')
1740                ages_fresh_balance = self.balance('ages', False)
1741                rest = case[total]['rest']
1742                if debug:
1743                    print('source', ages_cache_balance, ages_fresh_balance, rest)
1744                assert ages_cache_balance == rest
1745                assert ages_fresh_balance == rest
1746
1747                future_cache_balance = self.balance('future')
1748                future_fresh_balance = self.balance('future', False)
1749                if debug:
1750                    print('target', future_cache_balance, future_fresh_balance, total)
1751                    print('refs', refs)
1752                assert future_cache_balance == total
1753                assert future_fresh_balance == total
1754
1755                for ref in self._vault['account']['ages']['box']:
1756                    ages_capital = self._vault['account']['ages']['box'][ref]['capital']
1757                    ages_rest = self._vault['account']['ages']['box'][ref]['rest']
1758                    future_capital = 0
1759                    if ref in self._vault['account']['future']['box']:
1760                        future_capital = self._vault['account']['future']['box'][ref]['capital']
1761                    future_rest = 0
1762                    if ref in self._vault['account']['future']['box']:
1763                        future_rest = self._vault['account']['future']['box'][ref]['rest']
1764                    if ages_capital != 0 and future_capital != 0 and future_rest != 0:
1765                        if debug:
1766                            print('================================================================')
1767                            print('ages', ages_capital, ages_rest)
1768                            print('future', future_capital, future_rest)
1769                        if ages_rest == 0:
1770                            assert ages_capital == future_capital
1771                        elif ages_rest < 0:
1772                            assert -ages_capital == future_capital
1773                        elif ages_rest > 0:
1774                            assert ages_capital == ages_rest + future_capital
1775                self.reset()
1776                assert len(self._vault['history']) == 0
1777
1778            assert self._history()
1779            assert self._history(False) is False
1780            assert self._history() is False
1781            assert self._history(True)
1782            assert self._history()
1783
1784            self._test_core(True, debug)
1785            self._test_core(False, debug)
1786
1787            transaction = [
1788                (
1789                    20, 'wallet', 1, 800, 800, 800, 4, 5,
1790                    -85, -85, -85, 6, 7,
1791                ),
1792                (
1793                    750, 'wallet', 'safe', 50, 50, 50, 4, 6,
1794                    750, 750, 750, 1, 1,
1795                ),
1796                (
1797                    600, 'safe', 'bank', 150, 150, 150, 1, 2,
1798                    600, 600, 600, 1, 1,
1799                ),
1800            ]
1801            for z in transaction:
1802                self.lock()
1803                x = z[1]
1804                y = z[2]
1805                self.transfer(z[0], x, y, 'test-transfer', debug=debug)
1806                assert self.balance(x) == z[3]
1807                xx = self.accounts()[x]
1808                assert xx == z[3]
1809                assert self.balance(x, False) == z[4]
1810                assert xx == z[4]
1811
1812                s = 0
1813                log = self._vault['account'][x]['log']
1814                for i in log:
1815                    s += log[i]['value']
1816                if debug:
1817                    print('s', s, 'z[5]', z[5])
1818                assert s == z[5]
1819
1820                assert self.box_size(x) == z[6]
1821                assert self.log_size(x) == z[7]
1822
1823                yy = self.accounts()[y]
1824                assert self.balance(y) == z[8]
1825                assert yy == z[8]
1826                assert self.balance(y, False) == z[9]
1827                assert yy == z[9]
1828
1829                s = 0
1830                log = self._vault['account'][y]['log']
1831                for i in log:
1832                    s += log[i]['value']
1833                assert s == z[10]
1834
1835                assert self.box_size(y) == z[11]
1836                assert self.log_size(y) == z[12]
1837
1838            if debug:
1839                pp().pprint(self.check(2.17))
1840
1841            assert not self.nolock()
1842            history_count = len(self._vault['history'])
1843            if debug:
1844                print('history-count', history_count)
1845            assert history_count == 11
1846            assert not self.free(ZakatTracker.time())
1847            assert self.free(self.lock())
1848            assert self.nolock()
1849            assert len(self._vault['history']) == 11
1850
1851            # storage
1852
1853            _path = self.path('test.pickle')
1854            if os.path.exists(_path):
1855                os.remove(_path)
1856            self.save()
1857            assert os.path.getsize(_path) > 0
1858            self.reset()
1859            assert self.recall(False, debug) is False
1860            self.load()
1861            assert self._vault['account'] is not None
1862
1863            # recall
1864
1865            assert self.nolock()
1866            assert len(self._vault['history']) == 11
1867            assert self.recall(False, debug) is True
1868            assert len(self._vault['history']) == 10
1869            assert self.recall(False, debug) is True
1870            assert len(self._vault['history']) == 9
1871
1872            count = 1000
1873
1874            for with_rate, path in {
1875                False: 'test-import_csv-no-exchange',
1876                True: 'test-import_csv-with-exchange',
1877            }.items():
1878
1879                if debug:
1880                    print('test_import_csv', with_rate, path)
1881
1882                # csv
1883
1884                csv_path = path + '.csv'
1885                if os.path.exists(csv_path):
1886                    os.remove(csv_path)
1887                self.generate_random_csv_file(csv_path, count, with_rate)
1888                assert os.path.getsize(csv_path) > 0
1889                cache_path = self.import_csv_cache_path()
1890                if os.path.exists(cache_path):
1891                    os.remove(cache_path)
1892                # self.reset()
1893                (created, found, bad) = self.import_csv(csv_path, debug)
1894                bad_count = len(bad)
1895                if debug:
1896                    print(f"csv-imported: ({created}, {found}, {bad_count}) = count({count})")
1897                tmp_size = os.path.getsize(cache_path)
1898                assert tmp_size > 0
1899                assert created + found + bad_count == count
1900                assert created == count
1901                assert bad_count == 0
1902                (created_2, found_2, bad_2) = self.import_csv(csv_path)
1903                bad_2_count = len(bad_2)
1904                if debug:
1905                    print(f"csv-imported: ({created_2}, {found_2}, {bad_2_count})")
1906                    print(bad)
1907                assert tmp_size == os.path.getsize(cache_path)
1908                assert created_2 + found_2 + bad_2_count == count
1909                assert created == found_2
1910                assert bad_count == bad_2_count
1911                assert found_2 == count
1912                assert bad_2_count == 0
1913                assert created_2 == 0
1914
1915                # payment parts
1916
1917                positive_parts = self.build_payment_parts(100, positive_only=True)
1918                assert self.check_payment_parts(positive_parts) != 0
1919                assert self.check_payment_parts(positive_parts) != 0
1920                all_parts = self.build_payment_parts(300, positive_only=False)
1921                assert self.check_payment_parts(all_parts) != 0
1922                assert self.check_payment_parts(all_parts) != 0
1923                if debug:
1924                    pp().pprint(positive_parts)
1925                    pp().pprint(all_parts)
1926                # dynamic discount
1927                suite = []
1928                count = 3
1929                for exceed in [False, True]:
1930                    case = []
1931                    for parts in [positive_parts, all_parts]:
1932                        part = parts.copy()
1933                        demand = part['demand']
1934                        if debug:
1935                            print(demand, part['total'])
1936                        i = 0
1937                        z = demand / count
1938                        cp = {
1939                            'account': {},
1940                            'demand': demand,
1941                            'exceed': exceed,
1942                            'total': part['total'],
1943                        }
1944                        j = ''
1945                        for x, y in part['account'].items():
1946                            if exceed and z <= demand:
1947                                i += 1
1948                                y['part'] = z
1949                                if debug:
1950                                    print(exceed, y)
1951                                cp['account'][x] = y
1952                                case.append(y)
1953                            elif not exceed and y['balance'] >= z:
1954                                i += 1
1955                                y['part'] = z
1956                                if debug:
1957                                    print(exceed, y)
1958                                cp['account'][x] = y
1959                                case.append(y)
1960                            j = x
1961                            if i >= count:
1962                                break
1963                        if len(cp['account'][j]) > 0:
1964                            suite.append(cp)
1965                if debug:
1966                    print('suite', len(suite))
1967                for case in suite:
1968                    if debug:
1969                        print(case)
1970                    result = self.check_payment_parts(case)
1971                    if debug:
1972                        print('check_payment_parts', result)
1973                    assert result == 0
1974
1975                report = self.check(2.17, None, debug)
1976                (valid, brief, plan) = report
1977                if debug:
1978                    print('valid', valid)
1979                assert self.zakat(report, parts=suite, debug=debug)
1980                assert self.save(path + '.pickle')
1981                assert self.export_json(path + '.json')
1982
1983            # exchange
1984
1985            self.exchange("cash", 25, 3.75, "2024-06-25")
1986            self.exchange("cash", 22, 3.73, "2024-06-22")
1987            self.exchange("cash", 15, 3.69, "2024-06-15")
1988            self.exchange("cash", 10, 3.66)
1989
1990            for i in range(1, 30):
1991                rate, description = self.exchange("cash", i).values()
1992                if debug:
1993                    print(i, rate, description)
1994                if i < 10:
1995                    assert rate == 1
1996                    assert description is None
1997                elif i == 10:
1998                    assert rate == 3.66
1999                    assert description is None
2000                elif i < 15:
2001                    assert rate == 3.66
2002                    assert description is None
2003                elif i == 15:
2004                    assert rate == 3.69
2005                    assert description is not None
2006                elif i < 22:
2007                    assert rate == 3.69
2008                    assert description is not None
2009                elif i == 22:
2010                    assert rate == 3.73
2011                    assert description is not None
2012                elif i >= 25:
2013                    assert rate == 3.75
2014                    assert description is not None
2015                rate, description = self.exchange("bank", i).values()
2016                if debug:
2017                    print(i, rate, description)
2018                assert rate == 1
2019                assert description is None
2020
2021            assert len(self._vault['exchange']) > 0
2022            assert len(self.exchanges()) > 0
2023            self._vault['exchange'].clear()
2024            assert len(self._vault['exchange']) == 0
2025            assert len(self.exchanges()) == 0
2026
2027            # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية
2028            self.exchange("cash", ZakatTracker.day_to_time(25), 3.75, "2024-06-25")
2029            self.exchange("cash", ZakatTracker.day_to_time(22), 3.73, "2024-06-22")
2030            self.exchange("cash", ZakatTracker.day_to_time(15), 3.69, "2024-06-15")
2031            self.exchange("cash", ZakatTracker.day_to_time(10), 3.66)
2032
2033            for i in [x * 0.12 for x in range(-15, 21)]:
2034                if i <= 0:
2035                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) == 0
2036                else:
2037                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) > 0
2038
2039            # اختبار النتائج باستخدام التواريخ بالنانو ثانية
2040            for i in range(1, 31):
2041                timestamp_ns = ZakatTracker.day_to_time(i)
2042                rate, description = self.exchange("cash", timestamp_ns).values()
2043                if debug:
2044                    print(i, rate, description)
2045                if i < 10:
2046                    assert rate == 1
2047                    assert description is None
2048                elif i == 10:
2049                    assert rate == 3.66
2050                    assert description is None
2051                elif i < 15:
2052                    assert rate == 3.66
2053                    assert description is None
2054                elif i == 15:
2055                    assert rate == 3.69
2056                    assert description is not None
2057                elif i < 22:
2058                    assert rate == 3.69
2059                    assert description is not None
2060                elif i == 22:
2061                    assert rate == 3.73
2062                    assert description is not None
2063                elif i >= 25:
2064                    assert rate == 3.75
2065                    assert description is not None
2066                rate, description = self.exchange("bank", i).values()
2067                if debug:
2068                    print(i, rate, description)
2069                assert rate == 1
2070                assert description is None
2071
2072            assert self.export_json("1000-transactions-test.json")
2073            assert self.save("1000-transactions-test.pickle")
2074
2075            self.reset()
2076
2077            # test transfer between accounts with different exchange rate
2078
2079            a_SAR = "Bank (SAR)"
2080            b_USD = "Bank (USD)"
2081            c_SAR = "Safe (SAR)"
2082            # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer
2083            for case in [
2084                (0, a_SAR, "SAR Gift", 1000, 1000),
2085                (1, a_SAR, 1),
2086                (0, b_USD, "USD Gift", 500, 500),
2087                (1, b_USD, 1),
2088                (2, b_USD, 3.75),
2089                (1, b_USD, 3.75),
2090                (3, 100, b_USD, a_SAR, "100 USD -> SAR", 400, 1375),
2091                (0, c_SAR, "Salary", 750, 750),
2092                (3, 375, c_SAR, b_USD, "375 SAR -> USD", 375, 500),
2093                (3, 3.75, a_SAR, b_USD, "3.75 SAR -> USD", 1371.25, 501),
2094            ]:
2095                match (case[0]):
2096                    case 0:  # track
2097                        _, account, desc, x, balance = case
2098                        self.track(value=x, desc=desc, account=account, debug=debug)
2099
2100                        cached_value = self.balance(account, cached=True)
2101                        fresh_value = self.balance(account, cached=False)
2102                        if debug:
2103                            print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value)
2104                        assert cached_value == balance
2105                        assert fresh_value == balance
2106                    case 1:  # check-exchange
2107                        _, account, expected_rate = case
2108                        t_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
2109                        if debug:
2110                            print('t-exchange', t_exchange)
2111                        assert t_exchange['rate'] == expected_rate
2112                    case 2:  # do-exchange
2113                        _, account, rate = case
2114                        self.exchange(account, rate=rate, debug=debug)
2115                        b_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
2116                        if debug:
2117                            print('b-exchange', b_exchange)
2118                        assert b_exchange['rate'] == rate
2119                    case 3:  # transfer
2120                        _, x, a, b, desc, a_balance, b_balance = case
2121                        self.transfer(x, a, b, desc, debug=debug)
2122
2123                        cached_value = self.balance(a, cached=True)
2124                        fresh_value = self.balance(a, cached=False)
2125                        if debug:
2126                            print('account', a, 'cached_value', cached_value, 'fresh_value', fresh_value)
2127                        assert cached_value == a_balance
2128                        assert fresh_value == a_balance
2129
2130                        cached_value = self.balance(b, cached=True)
2131                        fresh_value = self.balance(b, cached=False)
2132                        if debug:
2133                            print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value)
2134                        assert cached_value == b_balance
2135                        assert fresh_value == b_balance
2136
2137            # Transfer all in many chunks randomly from B to A
2138            a_SAR_balance = 1371.25
2139            b_USD_balance = 501
2140            b_USD_exchange = self.exchange(b_USD)
2141            amounts = ZakatTracker.create_random_list(b_USD_balance)
2142            if debug:
2143                print('amounts', amounts)
2144            i = 0
2145            for x in amounts:
2146                if debug:
2147                    print(f'{i} - transfer-with-exchange({x})')
2148                self.transfer(x, b_USD, a_SAR, f"{x} USD -> SAR", debug=debug)
2149
2150                b_USD_balance -= x
2151                cached_value = self.balance(b_USD, cached=True)
2152                fresh_value = self.balance(b_USD, cached=False)
2153                if debug:
2154                    print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
2155                          b_USD_balance)
2156                assert cached_value == b_USD_balance
2157                assert fresh_value == b_USD_balance
2158
2159                a_SAR_balance += x * b_USD_exchange['rate']
2160                cached_value = self.balance(a_SAR, cached=True)
2161                fresh_value = self.balance(a_SAR, cached=False)
2162                if debug:
2163                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
2164                          a_SAR_balance, 'rate', b_USD_exchange['rate'])
2165                assert cached_value == a_SAR_balance
2166                assert fresh_value == a_SAR_balance
2167                i += 1
2168
2169            # Transfer all in many chunks randomly from C to A
2170            c_SAR_balance = 375
2171            amounts = ZakatTracker.create_random_list(c_SAR_balance)
2172            if debug:
2173                print('amounts', amounts)
2174            i = 0
2175            for x in amounts:
2176                if debug:
2177                    print(f'{i} - transfer-with-exchange({x})')
2178                self.transfer(x, c_SAR, a_SAR, f"{x} SAR -> a_SAR", debug=debug)
2179
2180                c_SAR_balance -= x
2181                cached_value = self.balance(c_SAR, cached=True)
2182                fresh_value = self.balance(c_SAR, cached=False)
2183                if debug:
2184                    print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
2185                          c_SAR_balance)
2186                assert cached_value == c_SAR_balance
2187                assert fresh_value == c_SAR_balance
2188
2189                a_SAR_balance += x
2190                cached_value = self.balance(a_SAR, cached=True)
2191                fresh_value = self.balance(a_SAR, cached=False)
2192                if debug:
2193                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
2194                          a_SAR_balance)
2195                assert cached_value == a_SAR_balance
2196                assert fresh_value == a_SAR_balance
2197                i += 1
2198
2199            assert self.export_json("accounts-transfer-with-exchange-rates.json")
2200            assert self.save("accounts-transfer-with-exchange-rates.pickle")
2201
2202            # check & zakat with exchange rates for many cycles
2203
2204            for rate, values in {
2205                1: {
2206                    'in': [1000, 2000, 10000],
2207                    'exchanged': [1000, 2000, 10000],
2208                    'out': [25, 50, 731.40625],
2209                },
2210                3.75: {
2211                    'in': [200, 1000, 5000],
2212                    'exchanged': [750, 3750, 18750],
2213                    'out': [18.75, 93.75, 1371.38671875],
2214                },
2215            }.items():
2216                a, b, c = values['in']
2217                m, n, o = values['exchanged']
2218                x, y, z = values['out']
2219                if debug:
2220                    print('rate', rate, 'values', values)
2221                for case in [
2222                    (a, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
2223                        {'safe': {0: {'below_nisab': x}}},
2224                    ], False, m),
2225                    (b, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
2226                        {'safe': {0: {'count': 1, 'total': y}}},
2227                    ], True, n),
2228                    (c, 'cave', ZakatTracker.time() - (ZakatTracker.TimeCycle() * 3), [
2229                        {'cave': {0: {'count': 3, 'total': z}}},
2230                    ], True, o),
2231                ]:
2232                    if debug:
2233                        print(f"############# check(rate: {rate}) #############")
2234                    self.reset()
2235                    self.exchange(account=case[1], created=case[2], rate=rate)
2236                    self.track(value=case[0], desc='test-check', account=case[1], logging=True, created=case[2])
2237
2238                    # assert self.nolock()
2239                    # history_size = len(self._vault['history'])
2240                    # print('history_size', history_size)
2241                    # assert history_size == 2
2242                    assert self.lock()
2243                    assert not self.nolock()
2244                    report = self.check(2.17, None, debug)
2245                    (valid, brief, plan) = report
2246                    assert valid == case[4]
2247                    if debug:
2248                        print('brief', brief)
2249                    assert case[5] == brief[0]
2250                    assert case[5] == brief[1]
2251
2252                    if debug:
2253                        pp().pprint(plan)
2254
2255                    for x in plan:
2256                        assert case[1] == x
2257                        if 'total' in case[3][0][x][0].keys():
2258                            assert case[3][0][x][0]['total'] == brief[2]
2259                            assert plan[x][0]['total'] == case[3][0][x][0]['total']
2260                            assert plan[x][0]['count'] == case[3][0][x][0]['count']
2261                        else:
2262                            assert plan[x][0]['below_nisab'] == case[3][0][x][0]['below_nisab']
2263                    if debug:
2264                        pp().pprint(report)
2265                    result = self.zakat(report, debug=debug)
2266                    if debug:
2267                        print('zakat-result', result, case[4])
2268                    assert result == case[4]
2269                    report = self.check(2.17, None, debug)
2270                    (valid, brief, plan) = report
2271                    assert valid is False
2272
2273            history_size = len(self._vault['history'])
2274            if debug:
2275                print('history_size', history_size)
2276            assert history_size == 3
2277            assert not self.nolock()
2278            assert self.recall(False, debug) is False
2279            self.free(self.lock())
2280            assert self.nolock()
2281            for i in range(3, 0, -1):
2282                history_size = len(self._vault['history'])
2283                if debug:
2284                    print('history_size', history_size)
2285                assert history_size == i
2286                assert self.recall(False, debug) is True
2287
2288            assert self.nolock()
2289
2290            assert self.recall(False, debug) is False
2291            history_size = len(self._vault['history'])
2292            if debug:
2293                print('history_size', history_size)
2294            assert history_size == 0
2295
2296            assert len(self._vault['account']) == 0
2297            assert len(self._vault['history']) == 0
2298            assert len(self._vault['report']) == 0
2299            assert self.nolock()
2300            return True
2301        except:
2302            # pp().pprint(self._vault)
2303            assert self.export_json("test-snapshot.json")
2304            assert self.save("test-snapshot.pickle")
2305            raise
class Action(enum.Enum):
69class Action(Enum):
70    CREATE = auto()
71    TRACK = auto()
72    LOG = auto()
73    SUB = auto()
74    ADD_FILE = auto()
75    REMOVE_FILE = auto()
76    BOX_TRANSFER = auto()
77    EXCHANGE = auto()
78    REPORT = auto()
79    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):
82class JSONEncoder(json.JSONEncoder):
83    def default(self, obj):
84        if isinstance(obj, Action) or isinstance(obj, MathOperation):
85            return obj.name  # Serialize as the enum member's name
86        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):
83    def default(self, obj):
84        if isinstance(obj, Action) or isinstance(obj, MathOperation):
85            return obj.name  # Serialize as the enum member's name
86        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):
89class MathOperation(Enum):
90    ADDITION = auto()
91    EQUAL = auto()
92    SUBTRACTION = auto()
ADDITION = <MathOperation.ADDITION: 1>
EQUAL = <MathOperation.EQUAL: 2>
SUBTRACTION = <MathOperation.SUBTRACTION: 3>