zakat
xxx

_____ _ _ _ _ _
|__ /__ _| | ____ _| |_ | | (_) |__ _ __ __ _ _ __ _ _ / // _| |/ / _ | __| | | | | '_ \| '__/ _` | '__| | | | / /| (_| | < (_| | |_ | |___| | |_) | | | (_| | | | |_| | /______,_|_|___,_|__| |_____|_|_.__/|_| __,_|_| __, | |___/

"رَبَّنَا افْتَحْ بَيْنَنَا وَبَيْنَ قَوْمِنَا بِالْحَقِّ وَأَنتَ خَيْرُ الْفَاتِحِينَ (89)" -- سورة الأعراف ... Never Trust, Always Verify ...

This file provides the ZakatLibrary classes, functions for tracking and calculating Zakat.

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

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:
240    @staticmethod
241    def time(now: datetime = None) -> int:
242        """
243        Generates a timestamp based on the provided datetime object or the current datetime.
244
245        Parameters:
246        now (datetime, optional): The datetime object to generate the timestamp from.
247        If not provided, the current datetime is used.
248
249        Returns:
250        int: The timestamp in positive nanoseconds since the Unix epoch (January 1, 1970),
251            before 1970 will return in negative until 1000AD.
252        """
253        if now is None:
254            now = datetime.datetime.now()
255        ordinal_day = now.toordinal()
256        ns_in_day = (now - now.replace(hour=0, minute=0, second=0, microsecond=0)).total_seconds() * 10 ** 9
257        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'>:
259    @staticmethod
260    def time_to_datetime(ordinal_ns: int) -> datetime:
261        """
262        Converts an ordinal number (number of days since 1000-01-01) to a datetime object.
263
264        Parameters:
265        ordinal_ns (int): The ordinal number of days since 1000-01-01.
266
267        Returns:
268        datetime: The corresponding datetime object.
269        """
270        ordinal_day = ordinal_ns // 86_400_000_000_000 + 719_163
271        ns_in_day = ordinal_ns % 86_400_000_000_000
272        d = datetime.datetime.fromordinal(ordinal_day)
273        t = datetime.timedelta(seconds=ns_in_day // 10 ** 9)
274        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:
312    def nolock(self) -> bool:
313        """
314        Check if the vault lock is currently not set.
315
316        :return: True if the vault lock is not set, False otherwise.
317        """
318        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:
320    def lock(self) -> int:
321        """
322        Acquires a lock on the ZakatTracker instance.
323
324        Returns:
325        int: The lock ID. This ID can be used to release the lock later.
326        """
327        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:
329    def box(self) -> dict:
330        """
331        Returns a copy of the internal vault dictionary.
332
333        This method is used to retrieve the current state of the ZakatTracker object.
334        It provides a snapshot of the internal data structure, allowing for further
335        processing or analysis.
336
337        :return: A copy of the internal vault dictionary.
338        """
339        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:
341    def steps(self) -> dict:
342        """
343        Returns a copy of the history of steps taken in the ZakatTracker.
344
345        The history is a dictionary where each key is a unique identifier for a step,
346        and the corresponding value is a dictionary containing information about the step.
347
348        :return: A copy of the history of steps taken in the ZakatTracker.
349        """
350        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:
352    def free(self, lock: int, auto_save: bool = True) -> bool:
353        """
354        Releases the lock on the database.
355
356        Parameters:
357        lock (int): The lock ID to be released.
358        auto_save (bool): Whether to automatically save the database after releasing the lock.
359
360        Returns:
361        bool: True if the lock is successfully released and (optionally) saved, False otherwise.
362        """
363        if lock == self._vault['lock']:
364            self._vault['lock'] = None
365            if auto_save:
366                return self.save(self.path())
367            return True
368        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:
370    def account_exists(self, account) -> bool:
371        """
372        Check if the given account exists in the vault.
373
374        Parameters:
375        account (str): The account number to check.
376
377        Returns:
378        bool: True if the account exists, False otherwise.
379        """
380        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:
382    def box_size(self, account) -> int:
383        """
384        Calculate the size of the box for a specific account.
385
386        Parameters:
387        account (str): The account number for which the box size needs to be calculated.
388
389        Returns:
390        int: The size of the box for the given account. If the account does not exist, -1 is returned.
391        """
392        if self.account_exists(account):
393            return len(self._vault['account'][account]['box'])
394        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:
396    def log_size(self, account) -> int:
397        """
398        Get the size of the log for a specific account.
399
400        Parameters:
401        account (str): The account number for which the log size needs to be calculated.
402
403        Returns:
404        int: The size of the log for the given account. If the account does not exist, -1 is returned.
405        """
406        if self.account_exists(account):
407            return len(self._vault['account'][account]['log'])
408        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:
410    def recall(self, dry=True, debug=False) -> bool:
411        """
412        Revert the last operation.
413
414        Parameters:
415        dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
416        debug (bool): If True, the function will print debug information. Default is False.
417
418        Returns:
419        bool: True if the operation was successful, False otherwise.
420        """
421        if not self.nolock() or len(self._vault['history']) == 0:
422            return False
423        if len(self._vault['history']) <= 0:
424            return False
425        ref = sorted(self._vault['history'].keys())[-1]
426        if debug:
427            print('recall', ref)
428        memory = self._vault['history'][ref]
429        if debug:
430            print(type(memory), 'memory', memory)
431
432        limit = len(memory) + 1
433        sub_positive_log_negative = 0
434        for i in range(-1, -limit, -1):
435            x = memory[i]
436            if debug:
437                print(type(x), x)
438            match x['action']:
439                case Action.CREATE:
440                    if x['account'] is not None:
441                        if self.account_exists(x['account']):
442                            if debug:
443                                print('account', self._vault['account'][x['account']])
444                            assert len(self._vault['account'][x['account']]['box']) == 0
445                            assert self._vault['account'][x['account']]['balance'] == 0
446                            assert self._vault['account'][x['account']]['count'] == 0
447                            if dry:
448                                continue
449                            del self._vault['account'][x['account']]
450
451                case Action.TRACK:
452                    if x['account'] is not None:
453                        if self.account_exists(x['account']):
454                            if dry:
455                                continue
456                            self._vault['account'][x['account']]['balance'] -= x['value']
457                            self._vault['account'][x['account']]['count'] -= 1
458                            del self._vault['account'][x['account']]['box'][x['ref']]
459
460                case Action.LOG:
461                    if x['account'] is not None:
462                        if self.account_exists(x['account']):
463                            if x['ref'] in self._vault['account'][x['account']]['log']:
464                                if dry:
465                                    continue
466                                if sub_positive_log_negative == -x['value']:
467                                    self._vault['account'][x['account']]['count'] -= 1
468                                    sub_positive_log_negative = 0
469                                del self._vault['account'][x['account']]['log'][x['ref']]
470
471                case Action.SUB:
472                    if x['account'] is not None:
473                        if self.account_exists(x['account']):
474                            if x['ref'] in self._vault['account'][x['account']]['box']:
475                                if dry:
476                                    continue
477                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] += x['value']
478                                self._vault['account'][x['account']]['balance'] += x['value']
479                                sub_positive_log_negative = x['value']
480
481                case Action.ADD_FILE:
482                    if x['account'] is not None:
483                        if self.account_exists(x['account']):
484                            if x['ref'] in self._vault['account'][x['account']]['log']:
485                                if x['file'] in self._vault['account'][x['account']]['log'][x['ref']]['file']:
486                                    if dry:
487                                        continue
488                                    del self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']]
489
490                case Action.REMOVE_FILE:
491                    if x['account'] is not None:
492                        if self.account_exists(x['account']):
493                            if x['ref'] in self._vault['account'][x['account']]['log']:
494                                if dry:
495                                    continue
496                                self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']] = x['value']
497
498                case Action.BOX_TRANSFER:
499                    if x['account'] is not None:
500                        if self.account_exists(x['account']):
501                            if x['ref'] in self._vault['account'][x['account']]['box']:
502                                if dry:
503                                    continue
504                                self._vault['account'][x['account']]['box'][x['ref']]['rest'] -= x['value']
505
506                case Action.EXCHANGE:
507                    if x['account'] is not None:
508                        if x['account'] in self._vault['exchange']:
509                            if x['ref'] in self._vault['exchange'][x['account']]:
510                                if dry:
511                                    continue
512                                del self._vault['exchange'][x['account']][x['ref']]
513
514                case Action.REPORT:
515                    if x['ref'] in self._vault['report']:
516                        if dry:
517                            continue
518                        del self._vault['report'][x['ref']]
519
520                case Action.ZAKAT:
521                    if x['account'] is not None:
522                        if self.account_exists(x['account']):
523                            if x['ref'] in self._vault['account'][x['account']]['box']:
524                                if x['key'] in self._vault['account'][x['account']]['box'][x['ref']]:
525                                    if dry:
526                                        continue
527                                    match x['math']:
528                                        case MathOperation.ADDITION:
529                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] -= x[
530                                                'value']
531                                        case MathOperation.EQUAL:
532                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] = x['value']
533                                        case MathOperation.SUBTRACTION:
534                                            self._vault['account'][x['account']]['box'][x['ref']][x['key']] += x[
535                                                'value']
536
537        if not dry:
538            del self._vault['history'][ref]
539        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:
541    def ref_exists(self, account: str, ref_type: str, ref: int) -> bool:
542        """
543        Check if a specific reference (transaction) exists in the vault for a given account and reference type.
544
545        Parameters:
546        account (str): The account number for which to check the existence of the reference.
547        ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
548        ref (int): The reference (transaction) number to check for existence.
549
550        Returns:
551        bool: True if the reference exists for the given account and reference type, False otherwise.
552        """
553        if account in self._vault['account']:
554            return ref in self._vault['account'][account][ref_type]
555        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:
557    def box_exists(self, account: str, ref: int) -> bool:
558        """
559        Check if a specific box (transaction) exists in the vault for a given account and reference.
560
561        Parameters:
562        - account (str): The account number for which to check the existence of the box.
563        - ref (int): The reference (transaction) number to check for existence.
564
565        Returns:
566        - bool: True if the box exists for the given account and reference, False otherwise.
567        """
568        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:
570    def track(self, value: float = 0, desc: str = '', account: str = 1, logging: bool = True, created: int = None,
571              debug: bool = False) -> int:
572        """
573        This function tracks a transaction for a specific account.
574
575        Parameters:
576        value (float): The value of the transaction. Default is 0.
577        desc (str): The description of the transaction. Default is an empty string.
578        account (str): The account for which the transaction is being tracked. Default is '1'.
579        logging (bool): Whether to log the transaction. Default is True.
580        created (int): The timestamp of the transaction. If not provided, it will be generated. Default is None.
581        debug (bool): Whether to print debug information. Default is False.
582
583        Returns:
584        int: The timestamp of the transaction.
585
586        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.
587
588        Raises:
589        ValueError: The log transaction happened again in the same nanosecond time.
590        ValueError: The box transaction happened again in the same nanosecond time.
591        """
592        if created is None:
593            created = self.time()
594        no_lock = self.nolock()
595        self.lock()
596        if not self.account_exists(account):
597            if debug:
598                print(f"account {account} created")
599            self._vault['account'][account] = {
600                'balance': 0,
601                'box': {},
602                'count': 0,
603                'log': {},
604                'hide': False,
605                'zakatable': True,
606            }
607            self._step(Action.CREATE, account)
608        if value == 0:
609            if no_lock:
610                self.free(self.lock())
611            return 0
612        if logging:
613            self._log(value, desc, account, created, debug)
614        if debug:
615            print('create-box', created)
616        if self.box_exists(account, created):
617            raise ValueError(f"The box transaction happened again in the same nanosecond time({created}).")
618        if debug:
619            print('created-box', created)
620        self._vault['account'][account]['box'][created] = {
621            'capital': value,
622            'count': 0,
623            'last': 0,
624            'rest': value,
625            'total': 0,
626        }
627        self._step(Action.TRACK, account, ref=created, value=value)
628        if no_lock:
629            self.free(self.lock())
630        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:
632    def log_exists(self, account: str, ref: int) -> bool:
633        """
634        Checks if a specific transaction log entry exists for a given account.
635
636        Parameters:
637        account (str): The account number associated with the transaction log.
638        ref (int): The reference to the transaction log entry.
639
640        Returns:
641        bool: True if the transaction log entry exists, False otherwise.
642        """
643        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:
682    def exchange(self, account, created: int = None, rate: float = None, description: str = None,
683                 debug: bool = False) -> dict:
684        """
685        This method is used to record or retrieve exchange rates for a specific account.
686
687        Parameters:
688        - account (str): The account number for which the exchange rate is being recorded or retrieved.
689        - created (int): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
690        - rate (float): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
691        - description (str): A description of the exchange rate.
692
693        Returns:
694        - dict: A dictionary containing the latest exchange rate and its description. If no exchange rate is found,
695        it returns a dictionary with default values for the rate and description.
696        """
697        if created is None:
698            created = self.time()
699        no_lock = self.nolock()
700        self.lock()
701        if rate is not None:
702            if rate <= 0:
703                return dict()
704            if account not in self._vault['exchange']:
705                self._vault['exchange'][account] = {}
706            if len(self._vault['exchange'][account]) == 0 and rate <= 1:
707                return {"rate": 1, "description": None}
708            self._vault['exchange'][account][created] = {"rate": rate, "description": description}
709            self._step(Action.EXCHANGE, account, ref=created, value=rate)
710            if no_lock:
711                self.free(self.lock())
712            if debug:
713                print("exchange-created-1",
714                      f'account: {account}, created: {created}, rate:{rate}, description:{description}')
715
716        if account in self._vault['exchange']:
717            valid_rates = [(ts, r) for ts, r in self._vault['exchange'][account].items() if ts <= created]
718            if valid_rates:
719                latest_rate = max(valid_rates, key=lambda x: x[0])
720                if debug:
721                    print("exchange-read-1",
722                          f'account: {account}, created: {created}, rate:{rate}, description:{description}',
723                          'latest_rate', latest_rate)
724                return latest_rate[1]  # إرجاع قاموس يحتوي على المعدل والوصف
725        if debug:
726            print("exchange-read-0", f'account: {account}, created: {created}, rate:{rate}, description:{description}')
727        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:
729    @staticmethod
730    def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
731        """
732        This function calculates the exchanged amount of a currency.
733
734        Args:
735            x (float): The original amount of the currency.
736            x_rate (float): The exchange rate of the original currency.
737            y_rate (float): The exchange rate of the target currency.
738
739        Returns:
740            float: The exchanged amount of the target currency.
741        """
742        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:
744    def exchanges(self) -> dict:
745        """
746        Retrieve the recorded exchange rates for all accounts.
747
748        Parameters:
749        None
750
751        Returns:
752        dict: A dictionary containing all recorded exchange rates.
753        The keys are account names or numbers, and the values are dictionaries containing the exchange rates.
754        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
755        """
756        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:
758    def accounts(self) -> dict:
759        """
760        Returns a dictionary containing account numbers as keys and their respective balances as values.
761
762        Parameters:
763        None
764
765        Returns:
766        dict: A dictionary where keys are account numbers and values are their respective balances.
767        """
768        result = {}
769        for i in self._vault['account']:
770            result[i] = self._vault['account'][i]['balance']
771        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:
773    def boxes(self, account) -> dict:
774        """
775        Retrieve the boxes (transactions) associated with a specific account.
776
777        Parameters:
778        account (str): The account number for which to retrieve the boxes.
779
780        Returns:
781        dict: A dictionary containing the boxes associated with the given account.
782        If the account does not exist, an empty dictionary is returned.
783        """
784        if self.account_exists(account):
785            return self._vault['account'][account]['box']
786        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:
788    def logs(self, account) -> dict:
789        """
790        Retrieve the logs (transactions) associated with a specific account.
791
792        Parameters:
793        account (str): The account number for which to retrieve the logs.
794
795        Returns:
796        dict: A dictionary containing the logs associated with the given account.
797        If the account does not exist, an empty dictionary is returned.
798        """
799        if self.account_exists(account):
800            return self._vault['account'][account]['log']
801        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:
803    def add_file(self, account: str, ref: int, path: str) -> int:
804        """
805        Adds a file reference to a specific transaction log entry in the vault.
806
807        Parameters:
808        account (str): The account number associated with the transaction log.
809        ref (int): The reference to the transaction log entry.
810        path (str): The path of the file to be added.
811
812        Returns:
813        int: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
814        """
815        if self.account_exists(account):
816            if ref in self._vault['account'][account]['log']:
817                file_ref = self.time()
818                self._vault['account'][account]['log'][ref]['file'][file_ref] = path
819                no_lock = self.nolock()
820                self.lock()
821                self._step(Action.ADD_FILE, account, ref=ref, file=file_ref)
822                if no_lock:
823                    self.free(self.lock())
824                return file_ref
825        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:
827    def remove_file(self, account: str, ref: int, file_ref: int) -> bool:
828        """
829        Removes a file reference from a specific transaction log entry in the vault.
830
831        Parameters:
832        account (str): The account number associated with the transaction log.
833        ref (int): The reference to the transaction log entry.
834        file_ref (int): The reference of the file to be removed.
835
836        Returns:
837        bool: True if the file reference is successfully removed, False otherwise.
838        """
839        if self.account_exists(account):
840            if ref in self._vault['account'][account]['log']:
841                if file_ref in self._vault['account'][account]['log'][ref]['file']:
842                    x = self._vault['account'][account]['log'][ref]['file'][file_ref]
843                    del self._vault['account'][account]['log'][ref]['file'][file_ref]
844                    no_lock = self.nolock()
845                    self.lock()
846                    self._step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x)
847                    if no_lock:
848                        self.free(self.lock())
849                    return True
850        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:
852    def balance(self, account: str = 1, cached: bool = True) -> int:
853        """
854        Calculate and return the balance of a specific account.
855
856        Parameters:
857        account (str): The account number. Default is '1'.
858        cached (bool): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
859
860        Returns:
861        int: The balance of the account.
862
863        Note:
864        If cached is True, the function returns the cached balance.
865        If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
866        """
867        if cached:
868            return self._vault['account'][account]['balance']
869        x = 0
870        return [x := x + y['rest'] for y in self._vault['account'][account]['box'].values()][-1]

Calculate and return the balance of a specific account.

Parameters: account (str): The account number. Default is '1'. cached (bool): If True, use the cached balance. If False, calculate the balance from the box. Default is True.

Returns: int: The balance of the account.

Note: If cached is True, the function returns the cached balance. If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.

def hide(self, account, status: bool = None) -> bool:
872    def hide(self, account, status: bool = None) -> bool:
873        """
874        Check or set the hide status of a specific account.
875
876        Parameters:
877        account (str): The account number.
878        status (bool, optional): The new hide status. If not provided, the function will return the current status.
879
880        Returns:
881        bool: The current or updated hide status of the account.
882
883        Raises:
884        None
885
886        Example:
887        >>> tracker = ZakatTracker()
888        >>> ref = tracker.track(51, 'desc', 'account1')
889        >>> tracker.hide('account1')  # Set the hide status of 'account1' to True
890        False
891        >>> tracker.hide('account1', True)  # Set the hide status of 'account1' to True
892        True
893        >>> tracker.hide('account1')  # Get the hide status of 'account1' by default
894        True
895        >>> tracker.hide('account1', False)
896        False
897        """
898        if self.account_exists(account):
899            if status is None:
900                return self._vault['account'][account]['hide']
901            self._vault['account'][account]['hide'] = status
902            return status
903        return False

Check or set the hide status of a specific account.

Parameters: account (str): The account number. status (bool, optional): The new hide status. If not provided, the function will return the current status.

Returns: bool: The current or updated hide status of the account.

Raises: None

Example:

>>> tracker = ZakatTracker()
>>> ref = tracker.track(51, 'desc', 'account1')
>>> tracker.hide('account1')  # Set the hide status of 'account1' to True
False
>>> tracker.hide('account1', True)  # Set the hide status of 'account1' to True
True
>>> tracker.hide('account1')  # Get the hide status of 'account1' by default
True
>>> tracker.hide('account1', False)
False
def zakatable(self, account, status: bool = None) -> bool:
905    def zakatable(self, account, status: bool = None) -> bool:
906        """
907        Check or set the zakatable status of a specific account.
908
909        Parameters:
910        account (str): The account number.
911        status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
912
913        Returns:
914        bool: The current or updated zakatable status of the account.
915
916        Raises:
917        None
918
919        Example:
920        >>> tracker = ZakatTracker()
921        >>> ref = tracker.track(51, 'desc', 'account1')
922        >>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
923        True
924        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
925        True
926        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
927        True
928        >>> tracker.zakatable('account1', False)
929        False
930        """
931        if self.account_exists(account):
932            if status is None:
933                return self._vault['account'][account]['zakatable']
934            self._vault['account'][account]['zakatable'] = status
935            return status
936        return False

Check or set the zakatable status of a specific account.

Parameters: account (str): The account number. status (bool, optional): The new zakatable status. If not provided, the function will return the current status.

Returns: bool: The current or updated zakatable status of the account.

Raises: None

Example:

>>> tracker = ZakatTracker()
>>> ref = tracker.track(51, 'desc', 'account1')
>>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
True
>>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
True
>>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
True
>>> tracker.zakatable('account1', False)
False
def sub( self, x: float, desc: str = '', account: str = 1, created: int = None, debug: bool = False) -> tuple:
 938    def sub(self, x: float, desc: str = '', account: str = 1, created: int = None, debug: bool = False) -> tuple:
 939        """
 940        Subtracts a specified value from an account's balance.
 941
 942        Parameters:
 943        x (float): The amount to be subtracted.
 944        desc (str): A description for the transaction. Defaults to an empty string.
 945        account (str): The account from which the value will be subtracted. Defaults to '1'.
 946        created (int): The timestamp of the transaction. If not provided, the current timestamp will be used.
 947        debug (bool): A flag indicating whether to print debug information. Defaults to False.
 948
 949        Returns:
 950        tuple: A tuple containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
 951
 952        If the amount to subtract is greater than the account's balance,
 953        the remaining amount will be transferred to a new transaction with a negative value.
 954
 955        Raises:
 956        ValueError: The box transaction happened again in the same nanosecond time.
 957        ValueError: The log transaction happened again in the same nanosecond time.
 958        """
 959        if x < 0:
 960            return tuple()
 961        if x == 0:
 962            ref = self.track(x, '', account)
 963            return ref, ref
 964        if created is None:
 965            created = self.time()
 966        no_lock = self.nolock()
 967        self.lock()
 968        self.track(0, '', account)
 969        self._log(-x, desc, account, created)
 970        ids = sorted(self._vault['account'][account]['box'].keys())
 971        limit = len(ids) + 1
 972        target = x
 973        if debug:
 974            print('ids', ids)
 975        ages = []
 976        for i in range(-1, -limit, -1):
 977            if target == 0:
 978                break
 979            j = ids[i]
 980            if debug:
 981                print('i', i, 'j', j)
 982            rest = self._vault['account'][account]['box'][j]['rest']
 983            if rest >= target:
 984                self._vault['account'][account]['box'][j]['rest'] -= target
 985                self._step(Action.SUB, account, ref=j, value=target)
 986                ages.append((j, target))
 987                target = 0
 988                break
 989            elif rest < target and rest > 0:
 990                chunk = rest
 991                target -= chunk
 992                self._step(Action.SUB, account, ref=j, value=chunk)
 993                ages.append((j, chunk))
 994                self._vault['account'][account]['box'][j]['rest'] = 0
 995        if target > 0:
 996            self.track(-target, desc, account, False, created)
 997            ages.append((created, target))
 998        if no_lock:
 999            self.free(self.lock())
1000        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]:
1002    def transfer(self, amount: int, from_account: str, to_account: str, desc: str = '', created: int = None,
1003                 debug: bool = False) -> list[int]:
1004        """
1005        Transfers a specified value from one account to another.
1006
1007        Parameters:
1008        amount (int): The amount to be transferred.
1009        from_account (str): The account from which the value will be transferred.
1010        to_account (str): The account to which the value will be transferred.
1011        desc (str, optional): A description for the transaction. Defaults to an empty string.
1012        created (int, optional): The timestamp of the transaction. If not provided, the current timestamp will be used.
1013        debug (bool): A flag indicating whether to print debug information. Defaults to False.
1014
1015        Returns:
1016        list[int]: A list of timestamps corresponding to the transactions made during the transfer.
1017
1018        Raises:
1019        ValueError: Transfer to the same account is forbidden.
1020        ValueError: The box transaction happened again in the same nanosecond time.
1021        ValueError: The log transaction happened again in the same nanosecond time.
1022        """
1023        if from_account == to_account:
1024            raise ValueError(f'Transfer to the same account is forbidden. {to_account}')
1025        if amount <= 0:
1026            return []
1027        if created is None:
1028            created = self.time()
1029        (_, ages) = self.sub(amount, desc, from_account, created, debug=debug)
1030        times = []
1031        source_exchange = self.exchange(from_account, created)
1032        target_exchange = self.exchange(to_account, created)
1033
1034        if debug:
1035            print('ages', ages)
1036
1037        for age, value in ages:
1038            target_amount = self.exchange_calc(value, source_exchange['rate'], target_exchange['rate'])
1039            # Perform the transfer
1040            if self.box_exists(to_account, age):
1041                if debug:
1042                    print('box_exists', age)
1043                capital = self._vault['account'][to_account]['box'][age]['capital']
1044                rest = self._vault['account'][to_account]['box'][age]['rest']
1045                if debug:
1046                    print(
1047                        f"Transfer {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1048                selected_age = age
1049                if rest + target_amount > capital:
1050                    self._vault['account'][to_account]['box'][age]['capital'] += target_amount
1051                    selected_age = ZakatTracker.time()
1052                self._vault['account'][to_account]['box'][age]['rest'] += target_amount
1053                self._step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount)
1054                y = self._log(target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account,
1055                              debug=debug)
1056                times.append((age, y))
1057                continue
1058            y = self.track(target_amount, desc, to_account, logging=True, created=age, debug=debug)
1059            if debug:
1060                print(
1061                    f"Transferred {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).")
1062            times.append(y)
1063        return times

Transfers a specified value from one account to another.

Parameters: amount (int): The amount to be transferred. from_account (str): The account from which the value will be transferred. to_account (str): The account to which the value will be transferred. desc (str, optional): A description for the transaction. Defaults to an empty string. created (int, optional): The timestamp of the transaction. If not provided, the current timestamp will be used. debug (bool): A flag indicating whether to print debug information. Defaults to False.

Returns: list[int]: A list of timestamps corresponding to the transactions made during the transfer.

Raises: ValueError: Transfer to the same account is forbidden. ValueError: The box transaction happened again in the same nanosecond time. ValueError: The log transaction happened again in the same nanosecond time.

def check( self, silver_gram_price: float, nisab: float = None, debug: bool = False, now: int = None, cycle: float = None) -> tuple:
1065    def check(self, silver_gram_price: float, nisab: float = None, debug: bool = False, now: int = None,
1066              cycle: float = None) -> tuple:
1067        """
1068        Check the eligibility for Zakat based on the given parameters.
1069
1070        Parameters:
1071        silver_gram_price (float): The price of a gram of silver.
1072        nisab (float): The minimum amount of wealth required for Zakat. If not provided,
1073                        it will be calculated based on the silver_gram_price.
1074        debug (bool): Flag to enable debug mode.
1075        now (int): The current timestamp. If not provided, it will be calculated using ZakatTracker.time().
1076        cycle (float): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
1077
1078        Returns:
1079        tuple: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics,
1080        and a dictionary containing the Zakat plan.
1081        """
1082        if now is None:
1083            now = self.time()
1084        if cycle is None:
1085            cycle = ZakatTracker.TimeCycle()
1086        if nisab is None:
1087            nisab = ZakatTracker.Nisab(silver_gram_price)
1088        plan = {}
1089        below_nisab = 0
1090        brief = [0, 0, 0]
1091        valid = False
1092        for x in self._vault['account']:
1093            if not self.zakatable(x):
1094                continue
1095            _box = self._vault['account'][x]['box']
1096            limit = len(_box) + 1
1097            ids = sorted(self._vault['account'][x]['box'].keys())
1098            for i in range(-1, -limit, -1):
1099                j = ids[i]
1100                rest = _box[j]['rest']
1101                if rest <= 0:
1102                    continue
1103                exchange = self.exchange(x, created=j)
1104                if debug:
1105                    print('exchanges', self.exchanges())
1106                rest = ZakatTracker.exchange_calc(rest, exchange['rate'], 1)
1107                brief[0] += rest
1108                index = limit + i - 1
1109                epoch = (now - j) / cycle
1110                if debug:
1111                    print(f"Epoch: {epoch}", _box[j])
1112                if _box[j]['last'] > 0:
1113                    epoch = (now - _box[j]['last']) / cycle
1114                if debug:
1115                    print(f"Epoch: {epoch}")
1116                epoch = floor(epoch)
1117                if debug:
1118                    print(f"Epoch: {epoch}", type(epoch), epoch == 0, 1 - epoch, epoch)
1119                if epoch == 0:
1120                    continue
1121                if debug:
1122                    print("Epoch - PASSED")
1123                brief[1] += rest
1124                if rest >= nisab:
1125                    total = 0
1126                    for _ in range(epoch):
1127                        total += ZakatTracker.ZakatCut(rest - total)
1128                    if total > 0:
1129                        if x not in plan:
1130                            plan[x] = {}
1131                        valid = True
1132                        brief[2] += total
1133                        plan[x][index] = {'total': total, 'count': epoch}
1134                else:
1135                    chunk = ZakatTracker.ZakatCut(rest)
1136                    if chunk > 0:
1137                        if x not in plan:
1138                            plan[x] = {}
1139                        if j not in plan[x].keys():
1140                            plan[x][index] = {}
1141                        below_nisab += rest
1142                        brief[2] += chunk
1143                        plan[x][index]['below_nisab'] = chunk
1144        valid = valid or below_nisab >= nisab
1145        if debug:
1146            print(f"below_nisab({below_nisab}) >= nisab({nisab})")
1147        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:
1149    def build_payment_parts(self, demand: float, positive_only: bool = True) -> dict:
1150        """
1151        Build payment parts for the zakat distribution.
1152
1153        Parameters:
1154        demand (float): The total demand for payment in local currency.
1155        positive_only (bool): If True, only consider accounts with positive balance. Default is True.
1156
1157        Returns:
1158        dict: A dictionary containing the payment parts for each account. The dictionary has the following structure:
1159        {
1160            'account': {
1161                'account_id': {'balance': float, 'rate': float, 'part': float},
1162                ...
1163            },
1164            'exceed': bool,
1165            'demand': float,
1166            'total': float,
1167        }
1168        """
1169        total = 0
1170        parts = {
1171            'account': {},
1172            'exceed': False,
1173            'demand': demand,
1174        }
1175        for x, y in self.accounts().items():
1176            if positive_only and y <= 0:
1177                continue
1178            total += y
1179            exchange = self.exchange(x)
1180            parts['account'][x] = {'balance': y, 'rate': exchange['rate'], 'part': 0}
1181        parts['total'] = total
1182        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:
1184    @staticmethod
1185    def check_payment_parts(parts: dict) -> int:
1186        """
1187        Checks the validity of payment parts.
1188
1189        Parameters:
1190        parts (dict): A dictionary containing payment parts information.
1191
1192        Returns:
1193        int: Returns 0 if the payment parts are valid, otherwise returns the error code.
1194
1195        Error Codes:
1196        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
1197        2: 'balance', 'rate' or 'part' key is missing in parts['account'][x].
1198        3: 'part' value in parts['account'][x] is less than or equal to 0.
1199        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
1200        5: 'part' value in parts['account'][x] is less than 0.
1201        6: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
1202        7: The sum of 'part' values in parts['account'] does not match with 'demand' value.
1203        """
1204        for i in ['demand', 'account', 'total', 'exceed']:
1205            if not i in parts:
1206                return 1
1207        exceed = parts['exceed']
1208        for x in parts['account']:
1209            for j in ['balance', 'rate', 'part']:
1210                if not j in parts['account'][x]:
1211                    return 2
1212                if parts['account'][x]['part'] <= 0:
1213                    return 3
1214                if not exceed and parts['account'][x]['balance'] <= 0:
1215                    return 4
1216        demand = parts['demand']
1217        z = 0
1218        for _, y in parts['account'].items():
1219            if y['part'] < 0:
1220                return 5
1221            if not exceed and y['part'] > y['balance']:
1222                return 6
1223            z += ZakatTracker.exchange_calc(y['part'], y['rate'], 1)
1224        if z != demand:
1225            return 7
1226        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:
1228    def zakat(self, report: tuple, parts: dict = None, debug: bool = False) -> bool:
1229        """
1230        Perform Zakat calculation based on the given report and optional parts.
1231
1232        Parameters:
1233        report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan.
1234        parts (dict): A dictionary containing the payment parts for the zakat.
1235        debug (bool): A flag indicating whether to print debug information.
1236
1237        Returns:
1238        bool: True if the zakat calculation is successful, False otherwise.
1239        """
1240        valid, _, plan = report
1241        if not valid:
1242            return valid
1243        parts_exist = parts is not None
1244        if parts_exist:
1245            for part in parts:
1246                if self.check_payment_parts(part) != 0:
1247                    return False
1248        if debug:
1249            print('######### zakat #######')
1250            print('parts_exist', parts_exist)
1251        no_lock = self.nolock()
1252        self.lock()
1253        report_time = self.time()
1254        self._vault['report'][report_time] = report
1255        self._step(Action.REPORT, ref=report_time)
1256        created = self.time()
1257        for x in plan:
1258            if debug:
1259                print(plan[x])
1260                print('-------------')
1261                print(self._vault['account'][x]['box'])
1262            ids = sorted(self._vault['account'][x]['box'].keys())
1263            if debug:
1264                print('plan[x]', plan[x])
1265            for i in plan[x].keys():
1266                j = ids[i]
1267                if debug:
1268                    print('i', i, 'j', j)
1269                self._step(Action.ZAKAT, account=x, ref=j, value=self._vault['account'][x]['box'][j]['last'],
1270                           key='last',
1271                           math_operation=MathOperation.EQUAL)
1272                self._vault['account'][x]['box'][j]['last'] = created
1273                self._vault['account'][x]['box'][j]['total'] += plan[x][i]['total']
1274                self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['total'], key='total',
1275                           math_operation=MathOperation.ADDITION)
1276                self._vault['account'][x]['box'][j]['count'] += plan[x][i]['count']
1277                self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['count'], key='count',
1278                           math_operation=MathOperation.ADDITION)
1279                if not parts_exist:
1280                    self._vault['account'][x]['box'][j]['rest'] -= plan[x][i]['total']
1281                    self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['total'], key='rest',
1282                               math_operation=MathOperation.SUBTRACTION)
1283        if parts_exist:
1284            for transaction in parts:
1285                for account, part in transaction['account'].items():
1286                    if debug:
1287                        print('zakat-part', account, part['part'])
1288                    target_exchange = self.exchange(account)
1289                    amount = ZakatTracker.exchange_calc(part['part'], part['rate'], target_exchange['rate'])
1290                    self.sub(amount, desc='zakat-part', account=account, debug=debug)
1291        if no_lock:
1292            self.free(self.lock())
1293        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:
1295    def export_json(self, path: str = "data.json") -> bool:
1296        """
1297        Exports the current state of the ZakatTracker object to a JSON file.
1298
1299        Parameters:
1300        path (str): The path where the JSON file will be saved. Default is "data.json".
1301
1302        Returns:
1303        bool: True if the export is successful, False otherwise.
1304
1305        Raises:
1306        No specific exceptions are raised by this method.
1307        """
1308        with open(path, "w") as file:
1309            json.dump(self._vault, file, indent=4, cls=JSONEncoder)
1310            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:
1312    def save(self, path: str = None) -> bool:
1313        """
1314        Saves the ZakatTracker's current state to a pickle file.
1315
1316        This method serializes the internal data (`_vault`) along with metadata
1317        (Python version, pickle protocol) for future compatibility.
1318
1319        Parameters:
1320            path (str, optional): File path for saving. Defaults to a predefined location.
1321
1322        Returns:
1323            bool: True if the save operation is successful, False otherwise.
1324        """
1325        if path is None:
1326            path = self.path()
1327        with open(path, "wb") as f:
1328            version = f'{version_info.major}.{version_info.minor}.{version_info.micro}'
1329            pickle_protocol = pickle.HIGHEST_PROTOCOL
1330            data = {
1331                'python_version': version,
1332                'pickle_protocol': pickle_protocol,
1333                'data': self._vault,
1334            }
1335            pickle.dump(data, f, protocol=pickle_protocol)
1336            return True

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

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

Parameters: path (str, optional): File path for saving. Defaults to a predefined location.

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

def load(self, path: str = None) -> bool:
1338    def load(self, path: str = None) -> bool:
1339        """
1340        Load the current state of the ZakatTracker object from a pickle file.
1341
1342        Parameters:
1343        path (str): The path where the pickle file is located. If not provided, it will use the default path.
1344
1345        Returns:
1346        bool: True if the load operation is successful, False otherwise.
1347        """
1348        if path is None:
1349            path = self.path()
1350        if os.path.exists(path):
1351            with open(path, "rb") as f:
1352                data = pickle.load(f)
1353                self._vault = data['data']
1354                return True
1355        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):
1357    def import_csv_cache_path(self):
1358        """
1359        Generates the cache file path for imported CSV data.
1360
1361        This function constructs the file path where cached data from CSV imports
1362        will be stored. The cache file is a pickle file (.pickle extension) appended
1363        to the base path of the object.
1364
1365        Returns:
1366            str: The full path to the import CSV cache file.
1367
1368        Example:
1369            >>> obj = ZakatTracker('/data/reports')
1370            >>> obj.import_csv_cache_path()
1371            '/data/reports.import_csv.pickle'
1372        """
1373        path = self.path()
1374        if path.endswith(".pickle"):
1375            path = path[:-7]
1376        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:
1378    def import_csv(self, path: str = 'file.csv', debug: bool = False) -> tuple:
1379        """
1380        The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system.
1381
1382        Parameters:
1383        path (str): The path to the CSV file. Default is 'file.csv'.
1384        debug (bool): A flag indicating whether to print debug information.
1385
1386        Returns:
1387        tuple: A tuple containing the number of transactions created, the number of transactions found in the cache,
1388                and a dictionary of bad transactions.
1389
1390        Notes:
1391            * Currency Pair Assumption: This function assumes that the exchange rates stored for each account
1392                                        are appropriate for the currency pairs involved in the conversions.
1393            * The exchange rate for each account is based on the last encountered transaction rate that is not equal
1394                to 1.0 or the previous rate for that account.
1395            * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent
1396              transactions of the same account within the whole imported and existing dataset when doing `check` and
1397              `zakat` operations.
1398
1399        Example Usage:
1400            The CSV file should have the following format, rate is optional per transaction:
1401            account, desc, value, date, rate
1402            For example:
1403            safe-45, "Some text", 34872, 1988-06-30 00:00:00, 1
1404        """
1405        cache: list[int] = []
1406        try:
1407            with open(self.import_csv_cache_path(), "rb") as f:
1408                cache = pickle.load(f)
1409        except:
1410            pass
1411        date_formats = [
1412            "%Y-%m-%d %H:%M:%S",
1413            "%Y-%m-%dT%H:%M:%S",
1414            "%Y-%m-%dT%H%M%S",
1415            "%Y-%m-%d",
1416        ]
1417        created, found, bad = 0, 0, {}
1418        data: list[tuple] = []
1419        with open(path, newline='', encoding="utf-8") as f:
1420            i = 0
1421            for row in csv.reader(f, delimiter=','):
1422                i += 1
1423                hashed = hash(tuple(row))
1424                if hashed in cache:
1425                    found += 1
1426                    continue
1427                account = row[0]
1428                desc = row[1]
1429                value = float(row[2])
1430                rate = 1.0
1431                if row[4:5]:  # Empty list if index is out of range
1432                    rate = float(row[4])
1433                date: int = 0
1434                for time_format in date_formats:
1435                    try:
1436                        date = self.time(datetime.datetime.strptime(row[3], time_format))
1437                        break
1438                    except:
1439                        pass
1440                # TODO: not allowed for negative dates
1441                if date == 0 or value == 0:
1442                    bad[i] = row
1443                    continue
1444                if date in data:
1445                    print('import_csv-duplicated(time)', date)
1446                    continue
1447                data.append((date, value, desc, account, rate, hashed))
1448
1449        if debug:
1450            print('import_csv', len(data))
1451        for row in sorted(data, key=lambda x: x[0]):
1452            (date, value, desc, account, rate, hashed) = row
1453            if rate > 1:
1454                self.exchange(account, created=date, rate=rate)
1455            if value > 0:
1456                self.track(value, desc, account, True, date)
1457            elif value < 0:
1458                self.sub(-value, desc, account, date)
1459            created += 1
1460            cache.append(hashed)
1461        with open(self.import_csv_cache_path(), "wb") as f:
1462            pickle.dump(cache, f)
1463        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:
1469    @staticmethod
1470    def duration_from_nanoseconds(ns: int) -> tuple:
1471        """
1472        REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
1473        Convert NanoSeconds to Human Readable Time Format.
1474        A NanoSeconds is a unit of time in the International System of Units (SI) equal
1475        to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
1476        Its symbol is μs, sometimes simplified to us when Unicode is not available.
1477        A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
1478
1479        INPUT : ms (AKA: MilliSeconds)
1480        OUTPUT: tuple(string time_lapsed, string spoken_time) like format.
1481        OUTPUT Variables: time_lapsed, spoken_time
1482
1483        Example  Input: duration_from_nanoseconds(ns)
1484        **"Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds"**
1485        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')
1486        duration_from_nanoseconds(1234567890123456789012)
1487        """
1488        us, ns = divmod(ns, 1000)
1489        ms, us = divmod(us, 1000)
1490        s, ms = divmod(ms, 1000)
1491        m, s = divmod(s, 60)
1492        h, m = divmod(m, 60)
1493        d, h = divmod(h, 24)
1494        y, d = divmod(d, 365)
1495        c, y = divmod(y, 100)
1496        n, c = divmod(c, 10)
1497        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}"
1498        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"
1499        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:
1501    @staticmethod
1502    def day_to_time(day: int, month: int = 6, year: int = 2024) -> int:  # افتراض أن الشهر هو يونيو والسنة 2024
1503        """
1504        Convert a specific day, month, and year into a timestamp.
1505
1506        Parameters:
1507        day (int): The day of the month.
1508        month (int): The month of the year. Default is 6 (June).
1509        year (int): The year. Default is 2024.
1510
1511        Returns:
1512        int: The timestamp representing the given day, month, and year.
1513
1514        Note:
1515        This method assumes the default month and year if not provided.
1516        """
1517        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:
1519    @staticmethod
1520    def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
1521        """
1522        Generate a random date between two given dates.
1523
1524        Parameters:
1525        start_date (datetime.datetime): The start date from which to generate a random date.
1526        end_date (datetime.datetime): The end date until which to generate a random date.
1527
1528        Returns:
1529        datetime.datetime: A random date between the start_date and end_date.
1530        """
1531        time_between_dates = end_date - start_date
1532        days_between_dates = time_between_dates.days
1533        random_number_of_days = random.randrange(days_between_dates)
1534        return start_date + datetime.timedelta(days=random_number_of_days)

Generate a random date between two given dates.

Parameters: start_date (datetime.datetime): The start date from which to generate a random date. end_date (datetime.datetime): The end date until which to generate a random date.

Returns: datetime.datetime: A random date between the start_date and end_date.

@staticmethod
def generate_random_csv_file( path: str = 'data.csv', count: int = 1000, with_rate: bool = False, debug: bool = False) -> int:
1536    @staticmethod
1537    def generate_random_csv_file(path: str = "data.csv", count: int = 1000, with_rate: bool = False,
1538                                 debug: bool = False) -> int:
1539        """
1540        Generate a random CSV file with specified parameters.
1541
1542        Parameters:
1543        path (str): The path where the CSV file will be saved. Default is "data.csv".
1544        count (int): The number of rows to generate in the CSV file. Default is 1000.
1545        with_rate (bool): If True, a random rate between 1.2% and 12% is added. Default is False.
1546        debug (bool): A flag indicating whether to print debug information.
1547
1548        Returns:
1549        None. The function generates a CSV file at the specified path with the given count of rows.
1550        Each row contains a randomly generated account, description, value, and date.
1551        The value is randomly generated between 1000 and 100000,
1552        and the date is randomly generated between 1950-01-01 and 2023-12-31.
1553        If the row number is not divisible by 13, the value is multiplied by -1.
1554        """
1555        i = 0
1556        with open(path, "w", newline="") as csvfile:
1557            writer = csv.writer(csvfile)
1558            for i in range(count):
1559                account = f"acc-{random.randint(1, 1000)}"
1560                desc = f"Some text {random.randint(1, 1000)}"
1561                value = random.randint(1000, 100000)
1562                date = ZakatTracker.generate_random_date(datetime.datetime(1950, 1, 1),
1563                                                         datetime.datetime(2023, 12, 31)).strftime("%Y-%m-%d %H:%M:%S")
1564                if not i % 13 == 0:
1565                    value *= -1
1566                row = [account, desc, value, date]
1567                if with_rate:
1568                    rate = random.randint(1, 100) * 0.12
1569                    if debug:
1570                        print('before-append', row)
1571                    row.append(rate)
1572                    if debug:
1573                        print('after-append', row)
1574                writer.writerow(row)
1575                i = i + 1
1576        return i

Generate a random CSV file with specified parameters.

Parameters: path (str): The path where the CSV file will be saved. Default is "data.csv". count (int): The number of rows to generate in the CSV file. Default is 1000. with_rate (bool): If True, a random rate between 1.2% and 12% is added. Default is False. debug (bool): A flag indicating whether to print debug information.

Returns: None. The function generates a CSV file at the specified path with the given count of rows. Each row contains a randomly generated account, description, value, and date. The value is randomly generated between 1000 and 100000, and the date is randomly generated between 1950-01-01 and 2023-12-31. If the row number is not divisible by 13, the value is multiplied by -1.

@staticmethod
def create_random_list(max_sum, min_value=0, max_value=10):
1578    @staticmethod
1579    def create_random_list(max_sum, min_value=0, max_value=10):
1580        """
1581        Creates a list of random integers whose sum does not exceed the specified maximum.
1582
1583        Args:
1584            max_sum: The maximum allowed sum of the list elements.
1585            min_value: The minimum possible value for an element (inclusive).
1586            max_value: The maximum possible value for an element (inclusive).
1587
1588        Returns:
1589            A list of random integers.
1590        """
1591        result = []
1592        current_sum = 0
1593
1594        while current_sum < max_sum:
1595            # Calculate the remaining space for the next element
1596            remaining_sum = max_sum - current_sum
1597            # Determine the maximum possible value for the next element
1598            next_max_value = min(remaining_sum, max_value)
1599            # Generate a random element within the allowed range
1600            next_element = random.randint(min_value, next_max_value)
1601            result.append(next_element)
1602            current_sum += next_element
1603
1604        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:
1750    def test(self, debug: bool = False) -> bool:
1751
1752        try:
1753
1754            assert self._history()
1755
1756            # Not allowed for duplicate transactions in the same account and time
1757
1758            created = ZakatTracker.time()
1759            self.track(100, 'test-1', 'same', True, created)
1760            failed = False
1761            try:
1762                self.track(50, 'test-1', 'same', True, created)
1763            except:
1764                failed = True
1765            assert failed is True
1766
1767            self.reset()
1768
1769            # Same account transfer
1770            for x in [1, 'a', True, 1.8, None]:
1771                failed = False
1772                try:
1773                    self.transfer(1, x, x, 'same-account', debug=debug)
1774                except:
1775                    failed = True
1776                assert failed is True
1777
1778            # Always preserve box age during transfer
1779
1780            series: list[tuple] = [
1781                (30, 4),
1782                (60, 3),
1783                (90, 2),
1784            ]
1785            case = {
1786                30: {
1787                    'series': series,
1788                    'rest': 150,
1789                },
1790                60: {
1791                    'series': series,
1792                    'rest': 120,
1793                },
1794                90: {
1795                    'series': series,
1796                    'rest': 90,
1797                },
1798                180: {
1799                    'series': series,
1800                    'rest': 0,
1801                },
1802                270: {
1803                    'series': series,
1804                    'rest': -90,
1805                },
1806                360: {
1807                    'series': series,
1808                    'rest': -180,
1809                },
1810            }
1811
1812            selected_time = ZakatTracker.time() - ZakatTracker.TimeCycle()
1813
1814            for total in case:
1815                for x in case[total]['series']:
1816                    self.track(x[0], f"test-{x} ages", 'ages', True, selected_time * x[1])
1817
1818                refs = self.transfer(total, 'ages', 'future', 'Zakat Movement', debug=debug)
1819
1820                if debug:
1821                    print('refs', refs)
1822
1823                ages_cache_balance = self.balance('ages')
1824                ages_fresh_balance = self.balance('ages', False)
1825                rest = case[total]['rest']
1826                if debug:
1827                    print('source', ages_cache_balance, ages_fresh_balance, rest)
1828                assert ages_cache_balance == rest
1829                assert ages_fresh_balance == rest
1830
1831                future_cache_balance = self.balance('future')
1832                future_fresh_balance = self.balance('future', False)
1833                if debug:
1834                    print('target', future_cache_balance, future_fresh_balance, total)
1835                    print('refs', refs)
1836                assert future_cache_balance == total
1837                assert future_fresh_balance == total
1838
1839                for ref in self._vault['account']['ages']['box']:
1840                    ages_capital = self._vault['account']['ages']['box'][ref]['capital']
1841                    ages_rest = self._vault['account']['ages']['box'][ref]['rest']
1842                    future_capital = 0
1843                    if ref in self._vault['account']['future']['box']:
1844                        future_capital = self._vault['account']['future']['box'][ref]['capital']
1845                    future_rest = 0
1846                    if ref in self._vault['account']['future']['box']:
1847                        future_rest = self._vault['account']['future']['box'][ref]['rest']
1848                    if ages_capital != 0 and future_capital != 0 and future_rest != 0:
1849                        if debug:
1850                            print('================================================================')
1851                            print('ages', ages_capital, ages_rest)
1852                            print('future', future_capital, future_rest)
1853                        if ages_rest == 0:
1854                            assert ages_capital == future_capital
1855                        elif ages_rest < 0:
1856                            assert -ages_capital == future_capital
1857                        elif ages_rest > 0:
1858                            assert ages_capital == ages_rest + future_capital
1859                self.reset()
1860                assert len(self._vault['history']) == 0
1861
1862            assert self._history()
1863            assert self._history(False) is False
1864            assert self._history() is False
1865            assert self._history(True)
1866            assert self._history()
1867
1868            self._test_core(True, debug)
1869            self._test_core(False, debug)
1870
1871            transaction = [
1872                (
1873                    20, 'wallet', 1, 800, 800, 800, 4, 5,
1874                    -85, -85, -85, 6, 7,
1875                ),
1876                (
1877                    750, 'wallet', 'safe', 50, 50, 50, 4, 6,
1878                    750, 750, 750, 1, 1,
1879                ),
1880                (
1881                    600, 'safe', 'bank', 150, 150, 150, 1, 2,
1882                    600, 600, 600, 1, 1,
1883                ),
1884            ]
1885            for z in transaction:
1886                self.lock()
1887                x = z[1]
1888                y = z[2]
1889                self.transfer(z[0], x, y, 'test-transfer', debug=debug)
1890                assert self.balance(x) == z[3]
1891                xx = self.accounts()[x]
1892                assert xx == z[3]
1893                assert self.balance(x, False) == z[4]
1894                assert xx == z[4]
1895
1896                s = 0
1897                log = self._vault['account'][x]['log']
1898                for i in log:
1899                    s += log[i]['value']
1900                if debug:
1901                    print('s', s, 'z[5]', z[5])
1902                assert s == z[5]
1903
1904                assert self.box_size(x) == z[6]
1905                assert self.log_size(x) == z[7]
1906
1907                yy = self.accounts()[y]
1908                assert self.balance(y) == z[8]
1909                assert yy == z[8]
1910                assert self.balance(y, False) == z[9]
1911                assert yy == z[9]
1912
1913                s = 0
1914                log = self._vault['account'][y]['log']
1915                for i in log:
1916                    s += log[i]['value']
1917                assert s == z[10]
1918
1919                assert self.box_size(y) == z[11]
1920                assert self.log_size(y) == z[12]
1921
1922            if debug:
1923                pp().pprint(self.check(2.17))
1924
1925            assert not self.nolock()
1926            history_count = len(self._vault['history'])
1927            if debug:
1928                print('history-count', history_count)
1929            assert history_count == 11
1930            assert not self.free(ZakatTracker.time())
1931            assert self.free(self.lock())
1932            assert self.nolock()
1933            assert len(self._vault['history']) == 11
1934
1935            # storage
1936
1937            _path = self.path('test.pickle')
1938            if os.path.exists(_path):
1939                os.remove(_path)
1940            self.save()
1941            assert os.path.getsize(_path) > 0
1942            self.reset()
1943            assert self.recall(False, debug) is False
1944            self.load()
1945            assert self._vault['account'] is not None
1946
1947            # recall
1948
1949            assert self.nolock()
1950            assert len(self._vault['history']) == 11
1951            assert self.recall(False, debug) is True
1952            assert len(self._vault['history']) == 10
1953            assert self.recall(False, debug) is True
1954            assert len(self._vault['history']) == 9
1955
1956            csv_count = 1000
1957
1958            for with_rate, path in {
1959                False: 'test-import_csv-no-exchange',
1960                True: 'test-import_csv-with-exchange',
1961            }.items():
1962
1963                if debug:
1964                    print('test_import_csv', with_rate, path)
1965
1966                # csv
1967
1968                csv_path = path + '.csv'
1969                if os.path.exists(csv_path):
1970                    os.remove(csv_path)
1971                c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug)
1972                if debug:
1973                    print('generate_random_csv_file', c)
1974                assert c == csv_count
1975                assert os.path.getsize(csv_path) > 0
1976                cache_path = self.import_csv_cache_path()
1977                if os.path.exists(cache_path):
1978                    os.remove(cache_path)
1979                self.reset()
1980                (created, found, bad) = self.import_csv(csv_path, debug)
1981                bad_count = len(bad)
1982                if debug:
1983                    print(f"csv-imported: ({created}, {found}, {bad_count}) = count({csv_count})")
1984                tmp_size = os.path.getsize(cache_path)
1985                assert tmp_size > 0
1986                assert created + found + bad_count == csv_count
1987                assert created == csv_count
1988                assert bad_count == 0
1989                (created_2, found_2, bad_2) = self.import_csv(csv_path)
1990                bad_2_count = len(bad_2)
1991                if debug:
1992                    print(f"csv-imported: ({created_2}, {found_2}, {bad_2_count})")
1993                    print(bad)
1994                assert tmp_size == os.path.getsize(cache_path)
1995                assert created_2 + found_2 + bad_2_count == csv_count
1996                assert created == found_2
1997                assert bad_count == bad_2_count
1998                assert found_2 == csv_count
1999                assert bad_2_count == 0
2000                assert created_2 == 0
2001
2002                # payment parts
2003
2004                positive_parts = self.build_payment_parts(100, positive_only=True)
2005                assert self.check_payment_parts(positive_parts) != 0
2006                assert self.check_payment_parts(positive_parts) != 0
2007                all_parts = self.build_payment_parts(300, positive_only=False)
2008                assert self.check_payment_parts(all_parts) != 0
2009                assert self.check_payment_parts(all_parts) != 0
2010                if debug:
2011                    pp().pprint(positive_parts)
2012                    pp().pprint(all_parts)
2013                # dynamic discount
2014                suite = []
2015                count = 3
2016                for exceed in [False, True]:
2017                    case = []
2018                    for parts in [positive_parts, all_parts]:
2019                        part = parts.copy()
2020                        demand = part['demand']
2021                        if debug:
2022                            print(demand, part['total'])
2023                        i = 0
2024                        z = demand / count
2025                        cp = {
2026                            'account': {},
2027                            'demand': demand,
2028                            'exceed': exceed,
2029                            'total': part['total'],
2030                        }
2031                        j = ''
2032                        for x, y in part['account'].items():
2033                            x_exchange = self.exchange(x)
2034                            zz = self.exchange_calc(z, 1, x_exchange['rate'])
2035                            if exceed and zz <= demand:
2036                                i += 1
2037                                y['part'] = zz
2038                                if debug:
2039                                    print(exceed, y)
2040                                cp['account'][x] = y
2041                                case.append(y)
2042                            elif not exceed and y['balance'] >= zz:
2043                                i += 1
2044                                y['part'] = zz
2045                                if debug:
2046                                    print(exceed, y)
2047                                cp['account'][x] = y
2048                                case.append(y)
2049                            j = x
2050                            if i >= count:
2051                                break
2052                        if len(cp['account'][j]) > 0:
2053                            suite.append(cp)
2054                if debug:
2055                    print('suite', len(suite))
2056                for case in suite:
2057                    if debug:
2058                        print(case)
2059                    result = self.check_payment_parts(case)
2060                    if debug:
2061                        print('check_payment_parts', result, f'exceed: {exceed}')
2062                    assert result == 0
2063
2064                report = self.check(2.17, None, debug)
2065                (valid, brief, plan) = report
2066                if debug:
2067                    print('valid', valid)
2068                assert self.zakat(report, parts=suite, debug=debug)
2069                assert self.save(path + '.pickle')
2070                assert self.export_json(path + '.json')
2071
2072            # exchange
2073
2074            self.exchange("cash", 25, 3.75, "2024-06-25")
2075            self.exchange("cash", 22, 3.73, "2024-06-22")
2076            self.exchange("cash", 15, 3.69, "2024-06-15")
2077            self.exchange("cash", 10, 3.66)
2078
2079            for i in range(1, 30):
2080                rate, description = self.exchange("cash", i).values()
2081                if debug:
2082                    print(i, rate, description)
2083                if i < 10:
2084                    assert rate == 1
2085                    assert description is None
2086                elif i == 10:
2087                    assert rate == 3.66
2088                    assert description is None
2089                elif i < 15:
2090                    assert rate == 3.66
2091                    assert description is None
2092                elif i == 15:
2093                    assert rate == 3.69
2094                    assert description is not None
2095                elif i < 22:
2096                    assert rate == 3.69
2097                    assert description is not None
2098                elif i == 22:
2099                    assert rate == 3.73
2100                    assert description is not None
2101                elif i >= 25:
2102                    assert rate == 3.75
2103                    assert description is not None
2104                rate, description = self.exchange("bank", i).values()
2105                if debug:
2106                    print(i, rate, description)
2107                assert rate == 1
2108                assert description is None
2109
2110            assert len(self._vault['exchange']) > 0
2111            assert len(self.exchanges()) > 0
2112            self._vault['exchange'].clear()
2113            assert len(self._vault['exchange']) == 0
2114            assert len(self.exchanges()) == 0
2115
2116            # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية
2117            self.exchange("cash", ZakatTracker.day_to_time(25), 3.75, "2024-06-25")
2118            self.exchange("cash", ZakatTracker.day_to_time(22), 3.73, "2024-06-22")
2119            self.exchange("cash", ZakatTracker.day_to_time(15), 3.69, "2024-06-15")
2120            self.exchange("cash", ZakatTracker.day_to_time(10), 3.66)
2121
2122            for i in [x * 0.12 for x in range(-15, 21)]:
2123                if i <= 0:
2124                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) == 0
2125                else:
2126                    assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) > 0
2127
2128            # اختبار النتائج باستخدام التواريخ بالنانو ثانية
2129            for i in range(1, 31):
2130                timestamp_ns = ZakatTracker.day_to_time(i)
2131                rate, description = self.exchange("cash", timestamp_ns).values()
2132                if debug:
2133                    print(i, rate, description)
2134                if i < 10:
2135                    assert rate == 1
2136                    assert description is None
2137                elif i == 10:
2138                    assert rate == 3.66
2139                    assert description is None
2140                elif i < 15:
2141                    assert rate == 3.66
2142                    assert description is None
2143                elif i == 15:
2144                    assert rate == 3.69
2145                    assert description is not None
2146                elif i < 22:
2147                    assert rate == 3.69
2148                    assert description is not None
2149                elif i == 22:
2150                    assert rate == 3.73
2151                    assert description is not None
2152                elif i >= 25:
2153                    assert rate == 3.75
2154                    assert description is not None
2155                rate, description = self.exchange("bank", i).values()
2156                if debug:
2157                    print(i, rate, description)
2158                assert rate == 1
2159                assert description is None
2160
2161            assert self.export_json("1000-transactions-test.json")
2162            assert self.save("1000-transactions-test.pickle")
2163
2164            self.reset()
2165
2166            # test transfer between accounts with different exchange rate
2167
2168            a_SAR = "Bank (SAR)"
2169            b_USD = "Bank (USD)"
2170            c_SAR = "Safe (SAR)"
2171            # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer
2172            for case in [
2173                (0, a_SAR, "SAR Gift", 1000, 1000),
2174                (1, a_SAR, 1),
2175                (0, b_USD, "USD Gift", 500, 500),
2176                (1, b_USD, 1),
2177                (2, b_USD, 3.75),
2178                (1, b_USD, 3.75),
2179                (3, 100, b_USD, a_SAR, "100 USD -> SAR", 400, 1375),
2180                (0, c_SAR, "Salary", 750, 750),
2181                (3, 375, c_SAR, b_USD, "375 SAR -> USD", 375, 500),
2182                (3, 3.75, a_SAR, b_USD, "3.75 SAR -> USD", 1371.25, 501),
2183            ]:
2184                match (case[0]):
2185                    case 0:  # track
2186                        _, account, desc, x, balance = case
2187                        self.track(value=x, desc=desc, account=account, debug=debug)
2188
2189                        cached_value = self.balance(account, cached=True)
2190                        fresh_value = self.balance(account, cached=False)
2191                        if debug:
2192                            print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value)
2193                        assert cached_value == balance
2194                        assert fresh_value == balance
2195                    case 1:  # check-exchange
2196                        _, account, expected_rate = case
2197                        t_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
2198                        if debug:
2199                            print('t-exchange', t_exchange)
2200                        assert t_exchange['rate'] == expected_rate
2201                    case 2:  # do-exchange
2202                        _, account, rate = case
2203                        self.exchange(account, rate=rate, debug=debug)
2204                        b_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug)
2205                        if debug:
2206                            print('b-exchange', b_exchange)
2207                        assert b_exchange['rate'] == rate
2208                    case 3:  # transfer
2209                        _, x, a, b, desc, a_balance, b_balance = case
2210                        self.transfer(x, a, b, desc, debug=debug)
2211
2212                        cached_value = self.balance(a, cached=True)
2213                        fresh_value = self.balance(a, cached=False)
2214                        if debug:
2215                            print('account', a, 'cached_value', cached_value, 'fresh_value', fresh_value)
2216                        assert cached_value == a_balance
2217                        assert fresh_value == a_balance
2218
2219                        cached_value = self.balance(b, cached=True)
2220                        fresh_value = self.balance(b, cached=False)
2221                        if debug:
2222                            print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value)
2223                        assert cached_value == b_balance
2224                        assert fresh_value == b_balance
2225
2226            # Transfer all in many chunks randomly from B to A
2227            a_SAR_balance = 1371.25
2228            b_USD_balance = 501
2229            b_USD_exchange = self.exchange(b_USD)
2230            amounts = ZakatTracker.create_random_list(b_USD_balance)
2231            if debug:
2232                print('amounts', amounts)
2233            i = 0
2234            for x in amounts:
2235                if debug:
2236                    print(f'{i} - transfer-with-exchange({x})')
2237                self.transfer(x, b_USD, a_SAR, f"{x} USD -> SAR", debug=debug)
2238
2239                b_USD_balance -= x
2240                cached_value = self.balance(b_USD, cached=True)
2241                fresh_value = self.balance(b_USD, cached=False)
2242                if debug:
2243                    print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
2244                          b_USD_balance)
2245                assert cached_value == b_USD_balance
2246                assert fresh_value == b_USD_balance
2247
2248                a_SAR_balance += x * b_USD_exchange['rate']
2249                cached_value = self.balance(a_SAR, cached=True)
2250                fresh_value = self.balance(a_SAR, cached=False)
2251                if debug:
2252                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
2253                          a_SAR_balance, 'rate', b_USD_exchange['rate'])
2254                assert cached_value == a_SAR_balance
2255                assert fresh_value == a_SAR_balance
2256                i += 1
2257
2258            # Transfer all in many chunks randomly from C to A
2259            c_SAR_balance = 375
2260            amounts = ZakatTracker.create_random_list(c_SAR_balance)
2261            if debug:
2262                print('amounts', amounts)
2263            i = 0
2264            for x in amounts:
2265                if debug:
2266                    print(f'{i} - transfer-with-exchange({x})')
2267                self.transfer(x, c_SAR, a_SAR, f"{x} SAR -> a_SAR", debug=debug)
2268
2269                c_SAR_balance -= x
2270                cached_value = self.balance(c_SAR, cached=True)
2271                fresh_value = self.balance(c_SAR, cached=False)
2272                if debug:
2273                    print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
2274                          c_SAR_balance)
2275                assert cached_value == c_SAR_balance
2276                assert fresh_value == c_SAR_balance
2277
2278                a_SAR_balance += x
2279                cached_value = self.balance(a_SAR, cached=True)
2280                fresh_value = self.balance(a_SAR, cached=False)
2281                if debug:
2282                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
2283                          a_SAR_balance)
2284                assert cached_value == a_SAR_balance
2285                assert fresh_value == a_SAR_balance
2286                i += 1
2287
2288            assert self.export_json("accounts-transfer-with-exchange-rates.json")
2289            assert self.save("accounts-transfer-with-exchange-rates.pickle")
2290
2291            # check & zakat with exchange rates for many cycles
2292
2293            for rate, values in {
2294                1: {
2295                    'in': [1000, 2000, 10000],
2296                    'exchanged': [1000, 2000, 10000],
2297                    'out': [25, 50, 731.40625],
2298                },
2299                3.75: {
2300                    'in': [200, 1000, 5000],
2301                    'exchanged': [750, 3750, 18750],
2302                    'out': [18.75, 93.75, 1371.38671875],
2303                },
2304            }.items():
2305                a, b, c = values['in']
2306                m, n, o = values['exchanged']
2307                x, y, z = values['out']
2308                if debug:
2309                    print('rate', rate, 'values', values)
2310                for case in [
2311                    (a, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
2312                        {'safe': {0: {'below_nisab': x}}},
2313                    ], False, m),
2314                    (b, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [
2315                        {'safe': {0: {'count': 1, 'total': y}}},
2316                    ], True, n),
2317                    (c, 'cave', ZakatTracker.time() - (ZakatTracker.TimeCycle() * 3), [
2318                        {'cave': {0: {'count': 3, 'total': z}}},
2319                    ], True, o),
2320                ]:
2321                    if debug:
2322                        print(f"############# check(rate: {rate}) #############")
2323                    self.reset()
2324                    self.exchange(account=case[1], created=case[2], rate=rate)
2325                    self.track(value=case[0], desc='test-check', account=case[1], logging=True, created=case[2])
2326
2327                    # assert self.nolock()
2328                    # history_size = len(self._vault['history'])
2329                    # print('history_size', history_size)
2330                    # assert history_size == 2
2331                    assert self.lock()
2332                    assert not self.nolock()
2333                    report = self.check(2.17, None, debug)
2334                    (valid, brief, plan) = report
2335                    assert valid == case[4]
2336                    if debug:
2337                        print('brief', brief)
2338                    assert case[5] == brief[0]
2339                    assert case[5] == brief[1]
2340
2341                    if debug:
2342                        pp().pprint(plan)
2343
2344                    for x in plan:
2345                        assert case[1] == x
2346                        if 'total' in case[3][0][x][0].keys():
2347                            assert case[3][0][x][0]['total'] == brief[2]
2348                            assert plan[x][0]['total'] == case[3][0][x][0]['total']
2349                            assert plan[x][0]['count'] == case[3][0][x][0]['count']
2350                        else:
2351                            assert plan[x][0]['below_nisab'] == case[3][0][x][0]['below_nisab']
2352                    if debug:
2353                        pp().pprint(report)
2354                    result = self.zakat(report, debug=debug)
2355                    if debug:
2356                        print('zakat-result', result, case[4])
2357                    assert result == case[4]
2358                    report = self.check(2.17, None, debug)
2359                    (valid, brief, plan) = report
2360                    assert valid is False
2361
2362            history_size = len(self._vault['history'])
2363            if debug:
2364                print('history_size', history_size)
2365            assert history_size == 3
2366            assert not self.nolock()
2367            assert self.recall(False, debug) is False
2368            self.free(self.lock())
2369            assert self.nolock()
2370            for i in range(3, 0, -1):
2371                history_size = len(self._vault['history'])
2372                if debug:
2373                    print('history_size', history_size)
2374                assert history_size == i
2375                assert self.recall(False, debug) is True
2376
2377            assert self.nolock()
2378
2379            assert self.recall(False, debug) is False
2380            history_size = len(self._vault['history'])
2381            if debug:
2382                print('history_size', history_size)
2383            assert history_size == 0
2384
2385            assert len(self._vault['account']) == 0
2386            assert len(self._vault['history']) == 0
2387            assert len(self._vault['report']) == 0
2388            assert self.nolock()
2389            return True
2390        except:
2391            # pp().pprint(self._vault)
2392            assert self.export_json("test-snapshot.json")
2393            assert self.save("test-snapshot.pickle")
2394            raise
class Action(enum.Enum):
71class Action(Enum):
72    CREATE = auto()
73    TRACK = auto()
74    LOG = auto()
75    SUB = auto()
76    ADD_FILE = auto()
77    REMOVE_FILE = auto()
78    BOX_TRANSFER = auto()
79    EXCHANGE = auto()
80    REPORT = auto()
81    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):
84class JSONEncoder(json.JSONEncoder):
85    def default(self, obj):
86        if isinstance(obj, Action) or isinstance(obj, MathOperation):
87            return obj.name  # Serialize as the enum member's name
88        elif isinstance(obj, Decimal):
89            return float(obj)
90        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):
85    def default(self, obj):
86        if isinstance(obj, Action) or isinstance(obj, MathOperation):
87            return obj.name  # Serialize as the enum member's name
88        elif isinstance(obj, Decimal):
89            return float(obj)
90        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):
93class MathOperation(Enum):
94    ADDITION = auto()
95    EQUAL = auto()
96    SUBTRACTION = auto()
ADDITION = <MathOperation.ADDITION: 1>
EQUAL = <MathOperation.EQUAL: 2>
SUBTRACTION = <MathOperation.SUBTRACTION: 3>
def start_file_server( database_path: str, database_callback: <built-in function callable> = None, csv_callback: <built-in function callable> = None, debug: bool = False) -> tuple:
 55def start_file_server(database_path: str, database_callback: callable = None, csv_callback: callable = None,
 56                      debug: bool = False) -> tuple:
 57    """
 58    Starts a multi-purpose HTTP server to manage file interactions for a Zakat application.
 59
 60    This server facilitates the following functionalities:
 61
 62    1. GET /{file_uuid}/get: Download the database file specified by `database_path`.
 63    2. GET /{file_uuid}/upload: Display an HTML form for uploading files.
 64    3. POST /{file_uuid}/upload: Handle file uploads, distinguishing between:
 65        - Database File (.db): Replaces the existing database with the uploaded one.
 66        - CSV File (.csv): Imports data from the CSV into the existing database.
 67
 68    Args:
 69        database_path (str): The path to the pickle database file.
 70        database_callback (callable, optional): A function to call after a successful database upload.
 71                                                It receives the uploaded database path as its argument.
 72        csv_callback (callable, optional): A function to call after a successful CSV upload. It receives the uploaded CSV path,
 73                                           the database path, and the debug flag as its arguments.
 74        debug (bool, optional): If True, print debugging information. Defaults to False.
 75
 76    Returns:
 77        Tuple[str, str, str, threading.Thread, Callable[[], None]]: A tuple containing:
 78            - file_name (str): The name of the database file.
 79            - download_url (str): The URL to download the database file.
 80            - upload_url (str): The URL to access the file upload form.
 81            - server_thread (threading.Thread): The thread running the server.
 82            - shutdown_server (Callable[[], None]): A function to gracefully shut down the server.
 83
 84    Example:
 85        _, download_url, upload_url, server_thread, shutdown_server = start_file_server("zakat.db")
 86        print(f"Download database: {download_url}")
 87        print(f"Upload files: {upload_url}")
 88        server_thread.start()
 89        # ... later ...
 90        shutdown_server()
 91    """
 92    file_uuid = uuid.uuid4()
 93    file_name = os.path.basename(database_path)
 94
 95    port = find_available_port()
 96    download_url = f"http://localhost:{port}/{file_uuid}/get"
 97    upload_url = f"http://localhost:{port}/{file_uuid}/upload"
 98
 99    class Handler(http.server.SimpleHTTPRequestHandler):
100        def do_GET(self):
101            if self.path == f"/{file_uuid}/get":
102                # GET: Serve the existing file
103                try:
104                    with open(database_path, "rb") as f:
105                        self.send_response(200)
106                        self.send_header("Content-type", "application/octet-stream")
107                        self.send_header("Content-Disposition", f'attachment; filename="{file_name}"')
108                        self.end_headers()
109                        self.wfile.write(f.read())
110                except FileNotFoundError:
111                    self.send_error(404, "File not found")
112            elif self.path == f"/{file_uuid}/upload":
113                # GET: Serve the upload form
114                self.send_response(200)
115                self.send_header("Content-type", "text/html")
116                self.end_headers()
117                self.wfile.write(f"""
118                    <html lang="en">
119                        <head>
120                            <title>Zakat File Server</title>
121                        </head>
122                    <body>
123                    <h1>Zakat File Server</h1>
124                    <h3>You can download the <a target="__blank" href="{download_url}">database file</a>...</h3>
125                    <h3>Or upload a new file to restore a database or import `CSV` file:</h3>
126                    <form action="/{file_uuid}/upload" method="post" enctype="multipart/form-data">
127                        <input type="file" name="file" required><br/>
128                        <input type="radio" id="{FileType.Database.value}" name="upload_type" value="{FileType.Database.value}" required>
129                        <label for="database">Database File</label><br/>
130                        <input type="radio"id="{FileType.CSV.value}" name="upload_type" value="{FileType.CSV.value}">
131                        <label for="csv">CSV File</label><br/>
132                        <input type="submit" value="Upload"><br/>
133                    </form>
134                    </body></html>
135                """.encode())
136            else:
137                self.send_error(404)
138
139        def do_POST(self):
140            if self.path == f"/{file_uuid}/upload":
141                # POST: Handle request
142                # 1. Get the Form Data
143                form_data = cgi.FieldStorage(
144                    fp=self.rfile,
145                    headers=self.headers,
146                    environ={'REQUEST_METHOD': 'POST'}
147                )
148                upload_type = form_data.getvalue("upload_type")
149
150                if debug:
151                    print('upload_type', upload_type)
152
153                if upload_type not in [FileType.Database.value, FileType.CSV.value]:
154                    self.send_error(400, "Invalid upload type")
155                    return
156
157                # 2. Extract File Data
158                file_item = form_data['file']  # Assuming 'file' is your file input name
159
160                # 3. Get File Details
161                filename = file_item.filename
162                file_data = file_item.file.read()  # Read the file's content
163
164                if debug:
165                    print(f'Uploaded filename: {filename}')
166
167                # 4. Define Storage Path for CSV
168                upload_directory = "./uploads"  # Create this directory if it doesn't exist
169                os.makedirs(upload_directory, exist_ok=True)
170                file_path = os.path.join(upload_directory, upload_type)
171
172                # 5. Write to Disk
173                with open(file_path, 'wb') as f:
174                    f.write(file_data)
175
176                match upload_type:
177                    case FileType.Database.value:
178
179                        try:
180                            # 6. Verify database file
181                            # ZakatTracker(db_path=file_path) # FATAL, Circular Imports Error
182                            if database_callback is not None:
183                                database_callback(file_path)
184
185                            # 7. Copy database into the original path
186                            shutil.copy2(file_path, database_path)
187                        except Exception as e:
188                            self.send_error(400, str(e))
189                            return
190
191                    case FileType.CSV.value:
192                        # 6. Verify CSV file
193                        try:
194                            # x = ZakatTracker(db_path=database_path) # FATAL, Circular Imports Error
195                            # result = x.import_csv(file_path, debug=debug)
196                            if csv_callback is not None:
197                                result = csv_callback(file_path, database_path, debug)
198                                if debug:
199                                    print(f'CSV imported: {result}')
200                                if len(result[2]) != 0:
201                                    self.send_response(200)
202                                    self.end_headers()
203                                    self.wfile.write(json.dumps(result).encode())
204                                    return
205                        except Exception as e:
206                            self.send_error(400, str(e))
207                            return
208
209                self.send_response(200)
210                self.end_headers()
211                self.wfile.write(b"File uploaded successfully.")
212
213    httpd = socketserver.TCPServer(("localhost", port), Handler)
214    server_thread = threading.Thread(target=httpd.serve_forever)
215
216    def shutdown_server():
217        nonlocal httpd, server_thread
218        httpd.shutdown()
219        httpd.server_close()  # Close the socket
220        server_thread.join()  # Wait for the thread to finish
221
222    return file_name, download_url, upload_url, server_thread, shutdown_server

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

This server facilitates the following functionalities:

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

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

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

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

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

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

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

Returns: int: The available TCP port number.

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

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

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