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