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