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