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