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.64' 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=j) 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] = {'total': total, 'count': epoch, 'box': j, 'log': _log[j]['desc']} 1196 else: 1197 chunk = ZakatTracker.ZakatCut(float(rest)) 1198 if chunk > 0: 1199 if x not in plan: 1200 plan[x] = {} 1201 if j not in plan[x].keys(): 1202 plan[x][index] = {} 1203 below_nisab += rest 1204 brief[2] += chunk 1205 plan[x][index]['below_nisab'] = chunk 1206 plan[x][index]['box'] = j 1207 plan[x][index]['log'] = _log[j]['desc'] 1208 valid = valid or below_nisab >= nisab 1209 if debug: 1210 print(f"below_nisab({below_nisab}) >= nisab({nisab})") 1211 return valid, brief, plan 1212 1213 def build_payment_parts(self, demand: float, positive_only: bool = True) -> dict: 1214 """ 1215 Build payment parts for the Zakat distribution. 1216 1217 Parameters: 1218 demand (float): The total demand for payment in local currency. 1219 positive_only (bool): If True, only consider accounts with positive balance. Default is True. 1220 1221 Returns: 1222 dict: A dictionary containing the payment parts for each account. The dictionary has the following structure: 1223 { 1224 'account': { 1225 'account_id': {'balance': float, 'rate': float, 'part': float}, 1226 ... 1227 }, 1228 'exceed': bool, 1229 'demand': float, 1230 'total': float, 1231 } 1232 """ 1233 total = 0 1234 parts = { 1235 'account': {}, 1236 'exceed': False, 1237 'demand': demand, 1238 } 1239 for x, y in self.accounts().items(): 1240 if positive_only and y <= 0: 1241 continue 1242 total += y 1243 exchange = self.exchange(x) 1244 parts['account'][x] = {'balance': y, 'rate': exchange['rate'], 'part': 0} 1245 parts['total'] = total 1246 return parts 1247 1248 @staticmethod 1249 def check_payment_parts(parts: dict) -> int: 1250 """ 1251 Checks the validity of payment parts. 1252 1253 Parameters: 1254 parts (dict): A dictionary containing payment parts information. 1255 1256 Returns: 1257 int: Returns 0 if the payment parts are valid, otherwise returns the error code. 1258 1259 Error Codes: 1260 1: 'demand', 'account', 'total', or 'exceed' key is missing in parts. 1261 2: 'balance', 'rate' or 'part' key is missing in parts['account'][x]. 1262 3: 'part' value in parts['account'][x] is less than or equal to 0. 1263 4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0. 1264 5: 'part' value in parts['account'][x] is less than 0. 1265 6: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value. 1266 7: The sum of 'part' values in parts['account'] does not match with 'demand' value. 1267 """ 1268 for i in ['demand', 'account', 'total', 'exceed']: 1269 if i not in parts: 1270 return 1 1271 exceed = parts['exceed'] 1272 for x in parts['account']: 1273 for j in ['balance', 'rate', 'part']: 1274 if j not in parts['account'][x]: 1275 return 2 1276 if parts['account'][x]['part'] <= 0: 1277 return 3 1278 if not exceed and parts['account'][x]['balance'] <= 0: 1279 return 4 1280 demand = parts['demand'] 1281 z = 0 1282 for _, y in parts['account'].items(): 1283 if y['part'] < 0: 1284 return 5 1285 if not exceed and y['part'] > y['balance']: 1286 return 6 1287 z += ZakatTracker.exchange_calc(y['part'], y['rate'], 1) 1288 if z != demand: 1289 return 7 1290 return 0 1291 1292 def zakat(self, report: tuple, parts: List[Dict[str, Dict | bool | Any]] = None, debug: bool = False) -> bool: 1293 """ 1294 Perform Zakat calculation based on the given report and optional parts. 1295 1296 Parameters: 1297 report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan. 1298 parts (dict): A dictionary containing the payment parts for the zakat. 1299 debug (bool): A flag indicating whether to print debug information. 1300 1301 Returns: 1302 bool: True if the zakat calculation is successful, False otherwise. 1303 """ 1304 valid, _, plan = report 1305 if not valid: 1306 return valid 1307 parts_exist = parts is not None 1308 if parts_exist: 1309 for part in parts: 1310 if self.check_payment_parts(part) != 0: 1311 return False 1312 if debug: 1313 print('######### zakat #######') 1314 print('parts_exist', parts_exist) 1315 no_lock = self.nolock() 1316 self.lock() 1317 report_time = self.time() 1318 self._vault['report'][report_time] = report 1319 self._step(Action.REPORT, ref=report_time) 1320 created = self.time() 1321 for x in plan: 1322 if debug: 1323 print(plan[x]) 1324 print('-------------') 1325 print(self._vault['account'][x]['box']) 1326 ids = sorted(self._vault['account'][x]['box'].keys()) 1327 if debug: 1328 print('plan[x]', plan[x]) 1329 for i in plan[x].keys(): 1330 j = ids[i] 1331 if debug: 1332 print('i', i, 'j', j) 1333 self._step(Action.ZAKAT, account=x, ref=j, value=self._vault['account'][x]['box'][j]['last'], 1334 key='last', 1335 math_operation=MathOperation.EQUAL) 1336 self._vault['account'][x]['box'][j]['last'] = created 1337 self._vault['account'][x]['box'][j]['total'] += plan[x][i]['total'] 1338 self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['total'], key='total', 1339 math_operation=MathOperation.ADDITION) 1340 self._vault['account'][x]['box'][j]['count'] += plan[x][i]['count'] 1341 self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['count'], key='count', 1342 math_operation=MathOperation.ADDITION) 1343 if not parts_exist: 1344 self._vault['account'][x]['box'][j]['rest'] -= plan[x][i]['total'] 1345 self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['total'], key='rest', 1346 math_operation=MathOperation.SUBTRACTION) 1347 if parts_exist: 1348 for transaction in parts: 1349 for account, part in transaction['account'].items(): 1350 if debug: 1351 print('zakat-part', account, part['part']) 1352 target_exchange = self.exchange(account) 1353 amount = ZakatTracker.exchange_calc(part['part'], part['rate'], target_exchange['rate']) 1354 self.sub(amount, desc='zakat-part', account=account, debug=debug) 1355 if no_lock: 1356 self.free(self.lock()) 1357 return True 1358 1359 def export_json(self, path: str = "data.json") -> bool: 1360 """ 1361 Exports the current state of the ZakatTracker object to a JSON file. 1362 1363 Parameters: 1364 path (str): The path where the JSON file will be saved. Default is "data.json". 1365 1366 Returns: 1367 bool: True if the export is successful, False otherwise. 1368 1369 Raises: 1370 No specific exceptions are raised by this method. 1371 """ 1372 with open(path, "w") as file: 1373 json.dump(self._vault, file, indent=4, cls=JSONEncoder) 1374 return True 1375 1376 def save(self, path: str = None) -> bool: 1377 """ 1378 Saves the ZakatTracker's current state to a pickle file. 1379 1380 This method serializes the internal data (`_vault`) along with metadata 1381 (Python version, pickle protocol) for future compatibility. 1382 1383 Parameters: 1384 path (str, optional): File path for saving. Defaults to a predefined location. 1385 1386 Returns: 1387 bool: True if the save operation is successful, False otherwise. 1388 """ 1389 if path is None: 1390 path = self.path() 1391 with open(path, "wb") as f: 1392 version = f'{version_info.major}.{version_info.minor}.{version_info.micro}' 1393 pickle_protocol = pickle.HIGHEST_PROTOCOL 1394 data = { 1395 'python_version': version, 1396 'pickle_protocol': pickle_protocol, 1397 'data': self._vault, 1398 } 1399 pickle.dump(data, f, protocol=pickle_protocol) 1400 return True 1401 1402 def load(self, path: str = None) -> bool: 1403 """ 1404 Load the current state of the ZakatTracker object from a pickle file. 1405 1406 Parameters: 1407 path (str): The path where the pickle file is located. If not provided, it will use the default path. 1408 1409 Returns: 1410 bool: True if the load operation is successful, False otherwise. 1411 """ 1412 if path is None: 1413 path = self.path() 1414 if os.path.exists(path): 1415 with open(path, "rb") as f: 1416 data = pickle.load(f) 1417 self._vault = data['data'] 1418 return True 1419 return False 1420 1421 def import_csv_cache_path(self): 1422 """ 1423 Generates the cache file path for imported CSV data. 1424 1425 This function constructs the file path where cached data from CSV imports 1426 will be stored. The cache file is a pickle file (.pickle extension) appended 1427 to the base path of the object. 1428 1429 Returns: 1430 str: The full path to the import CSV cache file. 1431 1432 Example: 1433 >>> obj = ZakatTracker('/data/reports') 1434 >>> obj.import_csv_cache_path() 1435 '/data/reports.import_csv.pickle' 1436 """ 1437 path = self.path() 1438 if path.endswith(".pickle"): 1439 path = path[:-7] 1440 return path + '.import_csv.pickle' 1441 1442 def import_csv(self, path: str = 'file.csv', debug: bool = False) -> tuple: 1443 """ 1444 The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system. 1445 1446 Parameters: 1447 path (str): The path to the CSV file. Default is 'file.csv'. 1448 debug (bool): A flag indicating whether to print debug information. 1449 1450 Returns: 1451 tuple: A tuple containing the number of transactions created, the number of transactions found in the cache, 1452 and a dictionary of bad transactions. 1453 1454 Notes: 1455 * Currency Pair Assumption: This function assumes that the exchange rates stored for each account 1456 are appropriate for the currency pairs involved in the conversions. 1457 * The exchange rate for each account is based on the last encountered transaction rate that is not equal 1458 to 1.0 or the previous rate for that account. 1459 * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent 1460 transactions of the same account within the whole imported and existing dataset when doing `check` and 1461 `zakat` operations. 1462 1463 Example Usage: 1464 The CSV file should have the following format, rate is optional per transaction: 1465 account, desc, value, date, rate 1466 For example: 1467 safe-45, "Some text", 34872, 1988-06-30 00:00:00, 1 1468 """ 1469 cache: list[int] = [] 1470 try: 1471 with open(self.import_csv_cache_path(), "rb") as f: 1472 cache = pickle.load(f) 1473 except: 1474 pass 1475 date_formats = [ 1476 "%Y-%m-%d %H:%M:%S", 1477 "%Y-%m-%dT%H:%M:%S", 1478 "%Y-%m-%dT%H%M%S", 1479 "%Y-%m-%d", 1480 ] 1481 created, found, bad = 0, 0, {} 1482 data: list[tuple] = [] 1483 with open(path, newline='', encoding="utf-8") as f: 1484 i = 0 1485 for row in csv.reader(f, delimiter=','): 1486 i += 1 1487 hashed = hash(tuple(row)) 1488 if hashed in cache: 1489 found += 1 1490 continue 1491 account = row[0] 1492 desc = row[1] 1493 value = float(row[2]) 1494 rate = 1.0 1495 if row[4:5]: # Empty list if index is out of range 1496 rate = float(row[4]) 1497 date: int = 0 1498 for time_format in date_formats: 1499 try: 1500 date = self.time(datetime.datetime.strptime(row[3], time_format)) 1501 break 1502 except: 1503 pass 1504 # TODO: not allowed for negative dates 1505 if date == 0 or value == 0: 1506 bad[i] = row 1507 continue 1508 if date in data: 1509 print('import_csv-duplicated(time)', date) 1510 continue 1511 data.append((date, value, desc, account, rate, hashed)) 1512 1513 if debug: 1514 print('import_csv', len(data)) 1515 for row in sorted(data, key=lambda x: x[0]): 1516 (date, value, desc, account, rate, hashed) = row 1517 if rate > 1: 1518 self.exchange(account, created=date, rate=rate) 1519 if value > 0: 1520 self.track(value, desc, account, True, date) 1521 elif value < 0: 1522 self.sub(-value, desc, account, date) 1523 created += 1 1524 cache.append(hashed) 1525 with open(self.import_csv_cache_path(), "wb") as f: 1526 pickle.dump(cache, f) 1527 return created, found, bad 1528 1529 ######## 1530 # TESTS # 1531 ####### 1532 1533 @staticmethod 1534 def duration_from_nanoseconds(ns: int) -> tuple: 1535 """ 1536 REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106 1537 Convert NanoSeconds to Human Readable Time Format. 1538 A NanoSeconds is a unit of time in the International System of Units (SI) equal 1539 to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second. 1540 Its symbol is μs, sometimes simplified to us when Unicode is not available. 1541 A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond. 1542 1543 INPUT : ms (AKA: MilliSeconds) 1544 OUTPUT: tuple(string time_lapsed, string spoken_time) like format. 1545 OUTPUT Variables: time_lapsed, spoken_time 1546 1547 Example Input: duration_from_nanoseconds(ns) 1548 **"Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds"** 1549 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') 1550 duration_from_nanoseconds(1234567890123456789012) 1551 """ 1552 us, ns = divmod(ns, 1000) 1553 ms, us = divmod(us, 1000) 1554 s, ms = divmod(ms, 1000) 1555 m, s = divmod(s, 60) 1556 h, m = divmod(m, 60) 1557 d, h = divmod(h, 24) 1558 y, d = divmod(d, 365) 1559 c, y = divmod(y, 100) 1560 n, c = divmod(c, 10) 1561 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}" 1562 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" 1563 return time_lapsed, spoken_time 1564 1565 @staticmethod 1566 def day_to_time(day: int, month: int = 6, year: int = 2024) -> int: # افتراض أن الشهر هو يونيو والسنة 2024 1567 """ 1568 Convert a specific day, month, and year into a timestamp. 1569 1570 Parameters: 1571 day (int): The day of the month. 1572 month (int): The month of the year. Default is 6 (June). 1573 year (int): The year. Default is 2024. 1574 1575 Returns: 1576 int: The timestamp representing the given day, month, and year. 1577 1578 Note: 1579 This method assumes the default month and year if not provided. 1580 """ 1581 return ZakatTracker.time(datetime.datetime(year, month, day)) 1582 1583 @staticmethod 1584 def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime: 1585 """ 1586 Generate a random date between two given dates. 1587 1588 Parameters: 1589 start_date (datetime.datetime): The start date from which to generate a random date. 1590 end_date (datetime.datetime): The end date until which to generate a random date. 1591 1592 Returns: 1593 datetime.datetime: A random date between the start_date and end_date. 1594 """ 1595 time_between_dates = end_date - start_date 1596 days_between_dates = time_between_dates.days 1597 random_number_of_days = random.randrange(days_between_dates) 1598 return start_date + datetime.timedelta(days=random_number_of_days) 1599 1600 @staticmethod 1601 def generate_random_csv_file(path: str = "data.csv", count: int = 1000, with_rate: bool = False, 1602 debug: bool = False) -> int: 1603 """ 1604 Generate a random CSV file with specified parameters. 1605 1606 Parameters: 1607 path (str): The path where the CSV file will be saved. Default is "data.csv". 1608 count (int): The number of rows to generate in the CSV file. Default is 1000. 1609 with_rate (bool): If True, a random rate between 1.2% and 12% is added. Default is False. 1610 debug (bool): A flag indicating whether to print debug information. 1611 1612 Returns: 1613 None. The function generates a CSV file at the specified path with the given count of rows. 1614 Each row contains a randomly generated account, description, value, and date. 1615 The value is randomly generated between 1000 and 100000, 1616 and the date is randomly generated between 1950-01-01 and 2023-12-31. 1617 If the row number is not divisible by 13, the value is multiplied by -1. 1618 """ 1619 i = 0 1620 with open(path, "w", newline="") as csvfile: 1621 writer = csv.writer(csvfile) 1622 for i in range(count): 1623 account = f"acc-{random.randint(1, 1000)}" 1624 desc = f"Some text {random.randint(1, 1000)}" 1625 value = random.randint(1000, 100000) 1626 date = ZakatTracker.generate_random_date(datetime.datetime(1950, 1, 1), 1627 datetime.datetime(2023, 12, 31)).strftime("%Y-%m-%d %H:%M:%S") 1628 if not i % 13 == 0: 1629 value *= -1 1630 row = [account, desc, value, date] 1631 if with_rate: 1632 rate = random.randint(1, 100) * 0.12 1633 if debug: 1634 print('before-append', row) 1635 row.append(rate) 1636 if debug: 1637 print('after-append', row) 1638 writer.writerow(row) 1639 i = i + 1 1640 return i 1641 1642 @staticmethod 1643 def create_random_list(max_sum, min_value=0, max_value=10): 1644 """ 1645 Creates a list of random integers whose sum does not exceed the specified maximum. 1646 1647 Args: 1648 max_sum: The maximum allowed sum of the list elements. 1649 min_value: The minimum possible value for an element (inclusive). 1650 max_value: The maximum possible value for an element (inclusive). 1651 1652 Returns: 1653 A list of random integers. 1654 """ 1655 result = [] 1656 current_sum = 0 1657 1658 while current_sum < max_sum: 1659 # Calculate the remaining space for the next element 1660 remaining_sum = max_sum - current_sum 1661 # Determine the maximum possible value for the next element 1662 next_max_value = min(remaining_sum, max_value) 1663 # Generate a random element within the allowed range 1664 next_element = random.randint(min_value, next_max_value) 1665 result.append(next_element) 1666 current_sum += next_element 1667 1668 return result 1669 1670 def _test_core(self, restore=False, debug=False): 1671 1672 random.seed(1234567890) 1673 1674 # sanity check - random forward time 1675 1676 xlist = [] 1677 limit = 1000 1678 for _ in range(limit): 1679 y = ZakatTracker.time() 1680 z = '-' 1681 if y not in xlist: 1682 xlist.append(y) 1683 else: 1684 z = 'x' 1685 if debug: 1686 print(z, y) 1687 xx = len(xlist) 1688 if debug: 1689 print('count', xx, ' - unique: ', (xx / limit) * 100, '%') 1690 assert limit == xx 1691 1692 # sanity check - convert date since 1000AD 1693 1694 for year in range(1000, 9000): 1695 ns = ZakatTracker.time(datetime.datetime.strptime(f"{year}-12-30 18:30:45", "%Y-%m-%d %H:%M:%S")) 1696 date = ZakatTracker.time_to_datetime(ns) 1697 if debug: 1698 print(date) 1699 assert date.year == year 1700 assert date.month == 12 1701 assert date.day == 30 1702 assert date.hour == 18 1703 assert date.minute == 30 1704 assert date.second in [44, 45] 1705 assert self.nolock() 1706 1707 assert self._history() is True 1708 1709 table = { 1710 1: [ 1711 (0, 10, 10, 10, 10, 1, 1), 1712 (0, 20, 30, 30, 30, 2, 2), 1713 (0, 30, 60, 60, 60, 3, 3), 1714 (1, 15, 45, 45, 45, 3, 4), 1715 (1, 50, -5, -5, -5, 4, 5), 1716 (1, 100, -105, -105, -105, 5, 6), 1717 ], 1718 'wallet': [ 1719 (1, 90, -90, -90, -90, 1, 1), 1720 (0, 100, 10, 10, 10, 2, 2), 1721 (1, 190, -180, -180, -180, 3, 3), 1722 (0, 1000, 820, 820, 820, 4, 4), 1723 ], 1724 } 1725 for x in table: 1726 for y in table[x]: 1727 self.lock() 1728 if y[0] == 0: 1729 ref = self.track(y[1], 'test-add', x, True, ZakatTracker.time(), debug) 1730 else: 1731 (ref, z) = self.sub(y[1], 'test-sub', x, ZakatTracker.time()) 1732 if debug: 1733 print('_sub', z, ZakatTracker.time()) 1734 assert ref != 0 1735 assert len(self._vault['account'][x]['log'][ref]['file']) == 0 1736 for i in range(3): 1737 file_ref = self.add_file(x, ref, 'file_' + str(i)) 1738 sleep(0.0000001) 1739 assert file_ref != 0 1740 if debug: 1741 print('ref', ref, 'file', file_ref) 1742 assert len(self._vault['account'][x]['log'][ref]['file']) == i + 1 1743 file_ref = self.add_file(x, ref, 'file_' + str(3)) 1744 assert self.remove_file(x, ref, file_ref) 1745 assert self.balance(x) == y[2] 1746 z = self.balance(x, False) 1747 if debug: 1748 print("debug-1", z, y[3]) 1749 assert z == y[3] 1750 o = self._vault['account'][x]['log'] 1751 z = 0 1752 for i in o: 1753 z += o[i]['value'] 1754 if debug: 1755 print("debug-2", z, type(z)) 1756 print("debug-2", y[4], type(y[4])) 1757 assert z == y[4] 1758 if debug: 1759 print('debug-2 - PASSED') 1760 assert self.box_size(x) == y[5] 1761 assert self.log_size(x) == y[6] 1762 assert not self.nolock() 1763 self.free(self.lock()) 1764 assert self.nolock() 1765 assert self.boxes(x) != {} 1766 assert self.logs(x) != {} 1767 1768 assert not self.hide(x) 1769 assert self.hide(x, False) is False 1770 assert self.hide(x) is False 1771 assert self.hide(x, True) 1772 assert self.hide(x) 1773 1774 assert self.zakatable(x) 1775 assert self.zakatable(x, False) is False 1776 assert self.zakatable(x) is False 1777 assert self.zakatable(x, True) 1778 assert self.zakatable(x) 1779 1780 if restore is True: 1781 count = len(self._vault['history']) 1782 if debug: 1783 print('history-count', count) 1784 assert count == 10 1785 # try mode 1786 for _ in range(count): 1787 assert self.recall(True, debug) 1788 count = len(self._vault['history']) 1789 if debug: 1790 print('history-count', count) 1791 assert count == 10 1792 _accounts = list(table.keys()) 1793 accounts_limit = len(_accounts) + 1 1794 for i in range(-1, -accounts_limit, -1): 1795 account = _accounts[i] 1796 if debug: 1797 print(account, len(table[account])) 1798 transaction_limit = len(table[account]) + 1 1799 for j in range(-1, -transaction_limit, -1): 1800 row = table[account][j] 1801 if debug: 1802 print(row, self.balance(account), self.balance(account, False)) 1803 assert self.balance(account) == self.balance(account, False) 1804 assert self.balance(account) == row[2] 1805 assert self.recall(False, debug) 1806 assert self.recall(False, debug) is False 1807 count = len(self._vault['history']) 1808 if debug: 1809 print('history-count', count) 1810 assert count == 0 1811 self.reset() 1812 1813 def test(self, debug: bool = False) -> bool: 1814 1815 try: 1816 1817 assert self._history() 1818 1819 # Not allowed for duplicate transactions in the same account and time 1820 1821 created = ZakatTracker.time() 1822 self.track(100, 'test-1', 'same', True, created) 1823 failed = False 1824 try: 1825 self.track(50, 'test-1', 'same', True, created) 1826 except: 1827 failed = True 1828 assert failed is True 1829 1830 self.reset() 1831 1832 # Same account transfer 1833 for x in [1, 'a', True, 1.8, None]: 1834 failed = False 1835 try: 1836 self.transfer(1, x, x, 'same-account', debug=debug) 1837 except: 1838 failed = True 1839 assert failed is True 1840 1841 # Always preserve box age during transfer 1842 1843 series: list[tuple] = [ 1844 (30, 4), 1845 (60, 3), 1846 (90, 2), 1847 ] 1848 case = { 1849 30: { 1850 'series': series, 1851 'rest': 150, 1852 }, 1853 60: { 1854 'series': series, 1855 'rest': 120, 1856 }, 1857 90: { 1858 'series': series, 1859 'rest': 90, 1860 }, 1861 180: { 1862 'series': series, 1863 'rest': 0, 1864 }, 1865 270: { 1866 'series': series, 1867 'rest': -90, 1868 }, 1869 360: { 1870 'series': series, 1871 'rest': -180, 1872 }, 1873 } 1874 1875 selected_time = ZakatTracker.time() - ZakatTracker.TimeCycle() 1876 1877 for total in case: 1878 for x in case[total]['series']: 1879 self.track(x[0], f"test-{x} ages", 'ages', True, selected_time * x[1]) 1880 1881 refs = self.transfer(total, 'ages', 'future', 'Zakat Movement', debug=debug) 1882 1883 if debug: 1884 print('refs', refs) 1885 1886 ages_cache_balance = self.balance('ages') 1887 ages_fresh_balance = self.balance('ages', False) 1888 rest = case[total]['rest'] 1889 if debug: 1890 print('source', ages_cache_balance, ages_fresh_balance, rest) 1891 assert ages_cache_balance == rest 1892 assert ages_fresh_balance == rest 1893 1894 future_cache_balance = self.balance('future') 1895 future_fresh_balance = self.balance('future', False) 1896 if debug: 1897 print('target', future_cache_balance, future_fresh_balance, total) 1898 print('refs', refs) 1899 assert future_cache_balance == total 1900 assert future_fresh_balance == total 1901 1902 for ref in self._vault['account']['ages']['box']: 1903 ages_capital = self._vault['account']['ages']['box'][ref]['capital'] 1904 ages_rest = self._vault['account']['ages']['box'][ref]['rest'] 1905 future_capital = 0 1906 if ref in self._vault['account']['future']['box']: 1907 future_capital = self._vault['account']['future']['box'][ref]['capital'] 1908 future_rest = 0 1909 if ref in self._vault['account']['future']['box']: 1910 future_rest = self._vault['account']['future']['box'][ref]['rest'] 1911 if ages_capital != 0 and future_capital != 0 and future_rest != 0: 1912 if debug: 1913 print('================================================================') 1914 print('ages', ages_capital, ages_rest) 1915 print('future', future_capital, future_rest) 1916 if ages_rest == 0: 1917 assert ages_capital == future_capital 1918 elif ages_rest < 0: 1919 assert -ages_capital == future_capital 1920 elif ages_rest > 0: 1921 assert ages_capital == ages_rest + future_capital 1922 self.reset() 1923 assert len(self._vault['history']) == 0 1924 1925 assert self._history() 1926 assert self._history(False) is False 1927 assert self._history() is False 1928 assert self._history(True) 1929 assert self._history() 1930 1931 self._test_core(True, debug) 1932 self._test_core(False, debug) 1933 1934 transaction = [ 1935 ( 1936 20, 'wallet', 1, 800, 800, 800, 4, 5, 1937 -85, -85, -85, 6, 7, 1938 ), 1939 ( 1940 750, 'wallet', 'safe', 50, 50, 50, 4, 6, 1941 750, 750, 750, 1, 1, 1942 ), 1943 ( 1944 600, 'safe', 'bank', 150, 150, 150, 1, 2, 1945 600, 600, 600, 1, 1, 1946 ), 1947 ] 1948 for z in transaction: 1949 self.lock() 1950 x = z[1] 1951 y = z[2] 1952 self.transfer(z[0], x, y, 'test-transfer', debug=debug) 1953 assert self.balance(x) == z[3] 1954 xx = self.accounts()[x] 1955 assert xx == z[3] 1956 assert self.balance(x, False) == z[4] 1957 assert xx == z[4] 1958 1959 s = 0 1960 log = self._vault['account'][x]['log'] 1961 for i in log: 1962 s += log[i]['value'] 1963 if debug: 1964 print('s', s, 'z[5]', z[5]) 1965 assert s == z[5] 1966 1967 assert self.box_size(x) == z[6] 1968 assert self.log_size(x) == z[7] 1969 1970 yy = self.accounts()[y] 1971 assert self.balance(y) == z[8] 1972 assert yy == z[8] 1973 assert self.balance(y, False) == z[9] 1974 assert yy == z[9] 1975 1976 s = 0 1977 log = self._vault['account'][y]['log'] 1978 for i in log: 1979 s += log[i]['value'] 1980 assert s == z[10] 1981 1982 assert self.box_size(y) == z[11] 1983 assert self.log_size(y) == z[12] 1984 1985 if debug: 1986 pp().pprint(self.check(2.17)) 1987 1988 assert not self.nolock() 1989 history_count = len(self._vault['history']) 1990 if debug: 1991 print('history-count', history_count) 1992 assert history_count == 11 1993 assert not self.free(ZakatTracker.time()) 1994 assert self.free(self.lock()) 1995 assert self.nolock() 1996 assert len(self._vault['history']) == 11 1997 1998 # storage 1999 2000 _path = self.path('test.pickle') 2001 if os.path.exists(_path): 2002 os.remove(_path) 2003 self.save() 2004 assert os.path.getsize(_path) > 0 2005 self.reset() 2006 assert self.recall(False, debug) is False 2007 self.load() 2008 assert self._vault['account'] is not None 2009 2010 # recall 2011 2012 assert self.nolock() 2013 assert len(self._vault['history']) == 11 2014 assert self.recall(False, debug) is True 2015 assert len(self._vault['history']) == 10 2016 assert self.recall(False, debug) is True 2017 assert len(self._vault['history']) == 9 2018 2019 csv_count = 1000 2020 2021 for with_rate, path in { 2022 False: 'test-import_csv-no-exchange', 2023 True: 'test-import_csv-with-exchange', 2024 }.items(): 2025 2026 if debug: 2027 print('test_import_csv', with_rate, path) 2028 2029 # csv 2030 2031 csv_path = path + '.csv' 2032 if os.path.exists(csv_path): 2033 os.remove(csv_path) 2034 c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug) 2035 if debug: 2036 print('generate_random_csv_file', c) 2037 assert c == csv_count 2038 assert os.path.getsize(csv_path) > 0 2039 cache_path = self.import_csv_cache_path() 2040 if os.path.exists(cache_path): 2041 os.remove(cache_path) 2042 self.reset() 2043 (created, found, bad) = self.import_csv(csv_path, debug) 2044 bad_count = len(bad) 2045 if debug: 2046 print(f"csv-imported: ({created}, {found}, {bad_count}) = count({csv_count})") 2047 tmp_size = os.path.getsize(cache_path) 2048 assert tmp_size > 0 2049 assert created + found + bad_count == csv_count 2050 assert created == csv_count 2051 assert bad_count == 0 2052 (created_2, found_2, bad_2) = self.import_csv(csv_path) 2053 bad_2_count = len(bad_2) 2054 if debug: 2055 print(f"csv-imported: ({created_2}, {found_2}, {bad_2_count})") 2056 print(bad) 2057 assert tmp_size == os.path.getsize(cache_path) 2058 assert created_2 + found_2 + bad_2_count == csv_count 2059 assert created == found_2 2060 assert bad_count == bad_2_count 2061 assert found_2 == csv_count 2062 assert bad_2_count == 0 2063 assert created_2 == 0 2064 2065 # payment parts 2066 2067 positive_parts = self.build_payment_parts(100, positive_only=True) 2068 assert self.check_payment_parts(positive_parts) != 0 2069 assert self.check_payment_parts(positive_parts) != 0 2070 all_parts = self.build_payment_parts(300, positive_only=False) 2071 assert self.check_payment_parts(all_parts) != 0 2072 assert self.check_payment_parts(all_parts) != 0 2073 if debug: 2074 pp().pprint(positive_parts) 2075 pp().pprint(all_parts) 2076 # dynamic discount 2077 suite = [] 2078 count = 3 2079 for exceed in [False, True]: 2080 case = [] 2081 for parts in [positive_parts, all_parts]: 2082 part = parts.copy() 2083 demand = part['demand'] 2084 if debug: 2085 print(demand, part['total']) 2086 i = 0 2087 z = demand / count 2088 cp = { 2089 'account': {}, 2090 'demand': demand, 2091 'exceed': exceed, 2092 'total': part['total'], 2093 } 2094 j = '' 2095 for x, y in part['account'].items(): 2096 x_exchange = self.exchange(x) 2097 zz = self.exchange_calc(z, 1, x_exchange['rate']) 2098 if exceed and zz <= demand: 2099 i += 1 2100 y['part'] = zz 2101 if debug: 2102 print(exceed, y) 2103 cp['account'][x] = y 2104 case.append(y) 2105 elif not exceed and y['balance'] >= zz: 2106 i += 1 2107 y['part'] = zz 2108 if debug: 2109 print(exceed, y) 2110 cp['account'][x] = y 2111 case.append(y) 2112 j = x 2113 if i >= count: 2114 break 2115 if len(cp['account'][j]) > 0: 2116 suite.append(cp) 2117 if debug: 2118 print('suite', len(suite)) 2119 for case in suite: 2120 if debug: 2121 print(case) 2122 result = self.check_payment_parts(case) 2123 if debug: 2124 print('check_payment_parts', result, f'exceed: {exceed}') 2125 assert result == 0 2126 2127 report = self.check(2.17, None, debug) 2128 (valid, brief, plan) = report 2129 if debug: 2130 print('valid', valid) 2131 assert self.zakat(report, parts=suite, debug=debug) 2132 assert self.save(path + '.pickle') 2133 assert self.export_json(path + '.json') 2134 2135 # exchange 2136 2137 self.exchange("cash", 25, 3.75, "2024-06-25") 2138 self.exchange("cash", 22, 3.73, "2024-06-22") 2139 self.exchange("cash", 15, 3.69, "2024-06-15") 2140 self.exchange("cash", 10, 3.66) 2141 2142 for i in range(1, 30): 2143 rate, description = self.exchange("cash", i).values() 2144 if debug: 2145 print(i, rate, description) 2146 if i < 10: 2147 assert rate == 1 2148 assert description is None 2149 elif i == 10: 2150 assert rate == 3.66 2151 assert description is None 2152 elif i < 15: 2153 assert rate == 3.66 2154 assert description is None 2155 elif i == 15: 2156 assert rate == 3.69 2157 assert description is not None 2158 elif i < 22: 2159 assert rate == 3.69 2160 assert description is not None 2161 elif i == 22: 2162 assert rate == 3.73 2163 assert description is not None 2164 elif i >= 25: 2165 assert rate == 3.75 2166 assert description is not None 2167 rate, description = self.exchange("bank", i).values() 2168 if debug: 2169 print(i, rate, description) 2170 assert rate == 1 2171 assert description is None 2172 2173 assert len(self._vault['exchange']) > 0 2174 assert len(self.exchanges()) > 0 2175 self._vault['exchange'].clear() 2176 assert len(self._vault['exchange']) == 0 2177 assert len(self.exchanges()) == 0 2178 2179 # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية 2180 self.exchange("cash", ZakatTracker.day_to_time(25), 3.75, "2024-06-25") 2181 self.exchange("cash", ZakatTracker.day_to_time(22), 3.73, "2024-06-22") 2182 self.exchange("cash", ZakatTracker.day_to_time(15), 3.69, "2024-06-15") 2183 self.exchange("cash", ZakatTracker.day_to_time(10), 3.66) 2184 2185 for i in [x * 0.12 for x in range(-15, 21)]: 2186 if i <= 0: 2187 assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) == 0 2188 else: 2189 assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) > 0 2190 2191 # اختبار النتائج باستخدام التواريخ بالنانو ثانية 2192 for i in range(1, 31): 2193 timestamp_ns = ZakatTracker.day_to_time(i) 2194 rate, description = self.exchange("cash", timestamp_ns).values() 2195 if debug: 2196 print(i, rate, description) 2197 if i < 10: 2198 assert rate == 1 2199 assert description is None 2200 elif i == 10: 2201 assert rate == 3.66 2202 assert description is None 2203 elif i < 15: 2204 assert rate == 3.66 2205 assert description is None 2206 elif i == 15: 2207 assert rate == 3.69 2208 assert description is not None 2209 elif i < 22: 2210 assert rate == 3.69 2211 assert description is not None 2212 elif i == 22: 2213 assert rate == 3.73 2214 assert description is not None 2215 elif i >= 25: 2216 assert rate == 3.75 2217 assert description is not None 2218 rate, description = self.exchange("bank", i).values() 2219 if debug: 2220 print(i, rate, description) 2221 assert rate == 1 2222 assert description is None 2223 2224 assert self.export_json("1000-transactions-test.json") 2225 assert self.save("1000-transactions-test.pickle") 2226 2227 self.reset() 2228 2229 # test transfer between accounts with different exchange rate 2230 2231 a_SAR = "Bank (SAR)" 2232 b_USD = "Bank (USD)" 2233 c_SAR = "Safe (SAR)" 2234 # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer 2235 for case in [ 2236 (0, a_SAR, "SAR Gift", 1000, 1000), 2237 (1, a_SAR, 1), 2238 (0, b_USD, "USD Gift", 500, 500), 2239 (1, b_USD, 1), 2240 (2, b_USD, 3.75), 2241 (1, b_USD, 3.75), 2242 (3, 100, b_USD, a_SAR, "100 USD -> SAR", 400, 1375), 2243 (0, c_SAR, "Salary", 750, 750), 2244 (3, 375, c_SAR, b_USD, "375 SAR -> USD", 375, 500), 2245 (3, 3.75, a_SAR, b_USD, "3.75 SAR -> USD", 1371.25, 501), 2246 ]: 2247 match (case[0]): 2248 case 0: # track 2249 _, account, desc, x, balance = case 2250 self.track(value=x, desc=desc, account=account, debug=debug) 2251 2252 cached_value = self.balance(account, cached=True) 2253 fresh_value = self.balance(account, cached=False) 2254 if debug: 2255 print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value) 2256 assert cached_value == balance 2257 assert fresh_value == balance 2258 case 1: # check-exchange 2259 _, account, expected_rate = case 2260 t_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug) 2261 if debug: 2262 print('t-exchange', t_exchange) 2263 assert t_exchange['rate'] == expected_rate 2264 case 2: # do-exchange 2265 _, account, rate = case 2266 self.exchange(account, rate=rate, debug=debug) 2267 b_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug) 2268 if debug: 2269 print('b-exchange', b_exchange) 2270 assert b_exchange['rate'] == rate 2271 case 3: # transfer 2272 _, x, a, b, desc, a_balance, b_balance = case 2273 self.transfer(x, a, b, desc, debug=debug) 2274 2275 cached_value = self.balance(a, cached=True) 2276 fresh_value = self.balance(a, cached=False) 2277 if debug: 2278 print('account', a, 'cached_value', cached_value, 'fresh_value', fresh_value) 2279 assert cached_value == a_balance 2280 assert fresh_value == a_balance 2281 2282 cached_value = self.balance(b, cached=True) 2283 fresh_value = self.balance(b, cached=False) 2284 if debug: 2285 print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value) 2286 assert cached_value == b_balance 2287 assert fresh_value == b_balance 2288 2289 # Transfer all in many chunks randomly from B to A 2290 a_SAR_balance = 1371.25 2291 b_USD_balance = 501 2292 b_USD_exchange = self.exchange(b_USD) 2293 amounts = ZakatTracker.create_random_list(b_USD_balance) 2294 if debug: 2295 print('amounts', amounts) 2296 i = 0 2297 for x in amounts: 2298 if debug: 2299 print(f'{i} - transfer-with-exchange({x})') 2300 self.transfer(x, b_USD, a_SAR, f"{x} USD -> SAR", debug=debug) 2301 2302 b_USD_balance -= x 2303 cached_value = self.balance(b_USD, cached=True) 2304 fresh_value = self.balance(b_USD, cached=False) 2305 if debug: 2306 print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', 2307 b_USD_balance) 2308 assert cached_value == b_USD_balance 2309 assert fresh_value == b_USD_balance 2310 2311 a_SAR_balance += x * b_USD_exchange['rate'] 2312 cached_value = self.balance(a_SAR, cached=True) 2313 fresh_value = self.balance(a_SAR, cached=False) 2314 if debug: 2315 print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', 2316 a_SAR_balance, 'rate', b_USD_exchange['rate']) 2317 assert cached_value == a_SAR_balance 2318 assert fresh_value == a_SAR_balance 2319 i += 1 2320 2321 # Transfer all in many chunks randomly from C to A 2322 c_SAR_balance = 375 2323 amounts = ZakatTracker.create_random_list(c_SAR_balance) 2324 if debug: 2325 print('amounts', amounts) 2326 i = 0 2327 for x in amounts: 2328 if debug: 2329 print(f'{i} - transfer-with-exchange({x})') 2330 self.transfer(x, c_SAR, a_SAR, f"{x} SAR -> a_SAR", debug=debug) 2331 2332 c_SAR_balance -= x 2333 cached_value = self.balance(c_SAR, cached=True) 2334 fresh_value = self.balance(c_SAR, cached=False) 2335 if debug: 2336 print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', 2337 c_SAR_balance) 2338 assert cached_value == c_SAR_balance 2339 assert fresh_value == c_SAR_balance 2340 2341 a_SAR_balance += x 2342 cached_value = self.balance(a_SAR, cached=True) 2343 fresh_value = self.balance(a_SAR, cached=False) 2344 if debug: 2345 print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', 2346 a_SAR_balance) 2347 assert cached_value == a_SAR_balance 2348 assert fresh_value == a_SAR_balance 2349 i += 1 2350 2351 assert self.export_json("accounts-transfer-with-exchange-rates.json") 2352 assert self.save("accounts-transfer-with-exchange-rates.pickle") 2353 2354 # check & zakat with exchange rates for many cycles 2355 2356 for rate, values in { 2357 1: { 2358 'in': [1000, 2000, 10000], 2359 'exchanged': [1000, 2000, 10000], 2360 'out': [25, 50, 731.40625], 2361 }, 2362 3.75: { 2363 'in': [200, 1000, 5000], 2364 'exchanged': [750, 3750, 18750], 2365 'out': [18.75, 93.75, 1371.38671875], 2366 }, 2367 }.items(): 2368 a, b, c = values['in'] 2369 m, n, o = values['exchanged'] 2370 x, y, z = values['out'] 2371 if debug: 2372 print('rate', rate, 'values', values) 2373 for case in [ 2374 (a, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [ 2375 {'safe': {0: {'below_nisab': x}}}, 2376 ], False, m), 2377 (b, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [ 2378 {'safe': {0: {'count': 1, 'total': y}}}, 2379 ], True, n), 2380 (c, 'cave', ZakatTracker.time() - (ZakatTracker.TimeCycle() * 3), [ 2381 {'cave': {0: {'count': 3, 'total': z}}}, 2382 ], True, o), 2383 ]: 2384 if debug: 2385 print(f"############# check(rate: {rate}) #############") 2386 self.reset() 2387 self.exchange(account=case[1], created=case[2], rate=rate) 2388 self.track(value=case[0], desc='test-check', account=case[1], logging=True, created=case[2]) 2389 2390 # assert self.nolock() 2391 # history_size = len(self._vault['history']) 2392 # print('history_size', history_size) 2393 # assert history_size == 2 2394 assert self.lock() 2395 assert not self.nolock() 2396 report = self.check(2.17, None, debug) 2397 (valid, brief, plan) = report 2398 assert valid == case[4] 2399 if debug: 2400 print('brief', brief) 2401 assert case[5] == brief[0] 2402 assert case[5] == brief[1] 2403 2404 if debug: 2405 pp().pprint(plan) 2406 2407 for x in plan: 2408 assert case[1] == x 2409 if 'total' in case[3][0][x][0].keys(): 2410 assert case[3][0][x][0]['total'] == brief[2] 2411 assert plan[x][0]['total'] == case[3][0][x][0]['total'] 2412 assert plan[x][0]['count'] == case[3][0][x][0]['count'] 2413 else: 2414 assert plan[x][0]['below_nisab'] == case[3][0][x][0]['below_nisab'] 2415 if debug: 2416 pp().pprint(report) 2417 result = self.zakat(report, debug=debug) 2418 if debug: 2419 print('zakat-result', result, case[4]) 2420 assert result == case[4] 2421 report = self.check(2.17, None, debug) 2422 (valid, brief, plan) = report 2423 assert valid is False 2424 2425 history_size = len(self._vault['history']) 2426 if debug: 2427 print('history_size', history_size) 2428 assert history_size == 3 2429 assert not self.nolock() 2430 assert self.recall(False, debug) is False 2431 self.free(self.lock()) 2432 assert self.nolock() 2433 for i in range(3, 0, -1): 2434 history_size = len(self._vault['history']) 2435 if debug: 2436 print('history_size', history_size) 2437 assert history_size == i 2438 assert self.recall(False, debug) is True 2439 2440 assert self.nolock() 2441 2442 assert self.recall(False, debug) is False 2443 history_size = len(self._vault['history']) 2444 if debug: 2445 print('history_size', history_size) 2446 assert history_size == 0 2447 2448 assert len(self._vault['account']) == 0 2449 assert len(self._vault['history']) == 0 2450 assert len(self._vault['report']) == 0 2451 assert self.nolock() 2452 return True 2453 except: 2454 # pp().pprint(self._vault) 2455 assert self.export_json("test-snapshot.json") 2456 assert self.save("test-snapshot.pickle") 2457 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.64'
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=j) 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] = {'total': total, 'count': epoch, 'box': j, 'log': _log[j]['desc']} 1196 else: 1197 chunk = ZakatTracker.ZakatCut(float(rest)) 1198 if chunk > 0: 1199 if x not in plan: 1200 plan[x] = {} 1201 if j not in plan[x].keys(): 1202 plan[x][index] = {} 1203 below_nisab += rest 1204 brief[2] += chunk 1205 plan[x][index]['below_nisab'] = chunk 1206 plan[x][index]['box'] = j 1207 plan[x][index]['log'] = _log[j]['desc'] 1208 valid = valid or below_nisab >= nisab 1209 if debug: 1210 print(f"below_nisab({below_nisab}) >= nisab({nisab})") 1211 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.
1213 def build_payment_parts(self, demand: float, positive_only: bool = True) -> dict: 1214 """ 1215 Build payment parts for the Zakat distribution. 1216 1217 Parameters: 1218 demand (float): The total demand for payment in local currency. 1219 positive_only (bool): If True, only consider accounts with positive balance. Default is True. 1220 1221 Returns: 1222 dict: A dictionary containing the payment parts for each account. The dictionary has the following structure: 1223 { 1224 'account': { 1225 'account_id': {'balance': float, 'rate': float, 'part': float}, 1226 ... 1227 }, 1228 'exceed': bool, 1229 'demand': float, 1230 'total': float, 1231 } 1232 """ 1233 total = 0 1234 parts = { 1235 'account': {}, 1236 'exceed': False, 1237 'demand': demand, 1238 } 1239 for x, y in self.accounts().items(): 1240 if positive_only and y <= 0: 1241 continue 1242 total += y 1243 exchange = self.exchange(x) 1244 parts['account'][x] = {'balance': y, 'rate': exchange['rate'], 'part': 0} 1245 parts['total'] = total 1246 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, }
1248 @staticmethod 1249 def check_payment_parts(parts: dict) -> int: 1250 """ 1251 Checks the validity of payment parts. 1252 1253 Parameters: 1254 parts (dict): A dictionary containing payment parts information. 1255 1256 Returns: 1257 int: Returns 0 if the payment parts are valid, otherwise returns the error code. 1258 1259 Error Codes: 1260 1: 'demand', 'account', 'total', or 'exceed' key is missing in parts. 1261 2: 'balance', 'rate' or 'part' key is missing in parts['account'][x]. 1262 3: 'part' value in parts['account'][x] is less than or equal to 0. 1263 4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0. 1264 5: 'part' value in parts['account'][x] is less than 0. 1265 6: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value. 1266 7: The sum of 'part' values in parts['account'] does not match with 'demand' value. 1267 """ 1268 for i in ['demand', 'account', 'total', 'exceed']: 1269 if i not in parts: 1270 return 1 1271 exceed = parts['exceed'] 1272 for x in parts['account']: 1273 for j in ['balance', 'rate', 'part']: 1274 if j not in parts['account'][x]: 1275 return 2 1276 if parts['account'][x]['part'] <= 0: 1277 return 3 1278 if not exceed and parts['account'][x]['balance'] <= 0: 1279 return 4 1280 demand = parts['demand'] 1281 z = 0 1282 for _, y in parts['account'].items(): 1283 if y['part'] < 0: 1284 return 5 1285 if not exceed and y['part'] > y['balance']: 1286 return 6 1287 z += ZakatTracker.exchange_calc(y['part'], y['rate'], 1) 1288 if z != demand: 1289 return 7 1290 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.
1292 def zakat(self, report: tuple, parts: List[Dict[str, Dict | bool | Any]] = None, debug: bool = False) -> bool: 1293 """ 1294 Perform Zakat calculation based on the given report and optional parts. 1295 1296 Parameters: 1297 report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan. 1298 parts (dict): A dictionary containing the payment parts for the zakat. 1299 debug (bool): A flag indicating whether to print debug information. 1300 1301 Returns: 1302 bool: True if the zakat calculation is successful, False otherwise. 1303 """ 1304 valid, _, plan = report 1305 if not valid: 1306 return valid 1307 parts_exist = parts is not None 1308 if parts_exist: 1309 for part in parts: 1310 if self.check_payment_parts(part) != 0: 1311 return False 1312 if debug: 1313 print('######### zakat #######') 1314 print('parts_exist', parts_exist) 1315 no_lock = self.nolock() 1316 self.lock() 1317 report_time = self.time() 1318 self._vault['report'][report_time] = report 1319 self._step(Action.REPORT, ref=report_time) 1320 created = self.time() 1321 for x in plan: 1322 if debug: 1323 print(plan[x]) 1324 print('-------------') 1325 print(self._vault['account'][x]['box']) 1326 ids = sorted(self._vault['account'][x]['box'].keys()) 1327 if debug: 1328 print('plan[x]', plan[x]) 1329 for i in plan[x].keys(): 1330 j = ids[i] 1331 if debug: 1332 print('i', i, 'j', j) 1333 self._step(Action.ZAKAT, account=x, ref=j, value=self._vault['account'][x]['box'][j]['last'], 1334 key='last', 1335 math_operation=MathOperation.EQUAL) 1336 self._vault['account'][x]['box'][j]['last'] = created 1337 self._vault['account'][x]['box'][j]['total'] += plan[x][i]['total'] 1338 self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['total'], key='total', 1339 math_operation=MathOperation.ADDITION) 1340 self._vault['account'][x]['box'][j]['count'] += plan[x][i]['count'] 1341 self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['count'], key='count', 1342 math_operation=MathOperation.ADDITION) 1343 if not parts_exist: 1344 self._vault['account'][x]['box'][j]['rest'] -= plan[x][i]['total'] 1345 self._step(Action.ZAKAT, account=x, ref=j, value=plan[x][i]['total'], key='rest', 1346 math_operation=MathOperation.SUBTRACTION) 1347 if parts_exist: 1348 for transaction in parts: 1349 for account, part in transaction['account'].items(): 1350 if debug: 1351 print('zakat-part', account, part['part']) 1352 target_exchange = self.exchange(account) 1353 amount = ZakatTracker.exchange_calc(part['part'], part['rate'], target_exchange['rate']) 1354 self.sub(amount, desc='zakat-part', account=account, debug=debug) 1355 if no_lock: 1356 self.free(self.lock()) 1357 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.
1359 def export_json(self, path: str = "data.json") -> bool: 1360 """ 1361 Exports the current state of the ZakatTracker object to a JSON file. 1362 1363 Parameters: 1364 path (str): The path where the JSON file will be saved. Default is "data.json". 1365 1366 Returns: 1367 bool: True if the export is successful, False otherwise. 1368 1369 Raises: 1370 No specific exceptions are raised by this method. 1371 """ 1372 with open(path, "w") as file: 1373 json.dump(self._vault, file, indent=4, cls=JSONEncoder) 1374 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.
1376 def save(self, path: str = None) -> bool: 1377 """ 1378 Saves the ZakatTracker's current state to a pickle file. 1379 1380 This method serializes the internal data (`_vault`) along with metadata 1381 (Python version, pickle protocol) for future compatibility. 1382 1383 Parameters: 1384 path (str, optional): File path for saving. Defaults to a predefined location. 1385 1386 Returns: 1387 bool: True if the save operation is successful, False otherwise. 1388 """ 1389 if path is None: 1390 path = self.path() 1391 with open(path, "wb") as f: 1392 version = f'{version_info.major}.{version_info.minor}.{version_info.micro}' 1393 pickle_protocol = pickle.HIGHEST_PROTOCOL 1394 data = { 1395 'python_version': version, 1396 'pickle_protocol': pickle_protocol, 1397 'data': self._vault, 1398 } 1399 pickle.dump(data, f, protocol=pickle_protocol) 1400 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.
1402 def load(self, path: str = None) -> bool: 1403 """ 1404 Load the current state of the ZakatTracker object from a pickle file. 1405 1406 Parameters: 1407 path (str): The path where the pickle file is located. If not provided, it will use the default path. 1408 1409 Returns: 1410 bool: True if the load operation is successful, False otherwise. 1411 """ 1412 if path is None: 1413 path = self.path() 1414 if os.path.exists(path): 1415 with open(path, "rb") as f: 1416 data = pickle.load(f) 1417 self._vault = data['data'] 1418 return True 1419 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.
1421 def import_csv_cache_path(self): 1422 """ 1423 Generates the cache file path for imported CSV data. 1424 1425 This function constructs the file path where cached data from CSV imports 1426 will be stored. The cache file is a pickle file (.pickle extension) appended 1427 to the base path of the object. 1428 1429 Returns: 1430 str: The full path to the import CSV cache file. 1431 1432 Example: 1433 >>> obj = ZakatTracker('/data/reports') 1434 >>> obj.import_csv_cache_path() 1435 '/data/reports.import_csv.pickle' 1436 """ 1437 path = self.path() 1438 if path.endswith(".pickle"): 1439 path = path[:-7] 1440 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'
1442 def import_csv(self, path: str = 'file.csv', debug: bool = False) -> tuple: 1443 """ 1444 The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system. 1445 1446 Parameters: 1447 path (str): The path to the CSV file. Default is 'file.csv'. 1448 debug (bool): A flag indicating whether to print debug information. 1449 1450 Returns: 1451 tuple: A tuple containing the number of transactions created, the number of transactions found in the cache, 1452 and a dictionary of bad transactions. 1453 1454 Notes: 1455 * Currency Pair Assumption: This function assumes that the exchange rates stored for each account 1456 are appropriate for the currency pairs involved in the conversions. 1457 * The exchange rate for each account is based on the last encountered transaction rate that is not equal 1458 to 1.0 or the previous rate for that account. 1459 * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent 1460 transactions of the same account within the whole imported and existing dataset when doing `check` and 1461 `zakat` operations. 1462 1463 Example Usage: 1464 The CSV file should have the following format, rate is optional per transaction: 1465 account, desc, value, date, rate 1466 For example: 1467 safe-45, "Some text", 34872, 1988-06-30 00:00:00, 1 1468 """ 1469 cache: list[int] = [] 1470 try: 1471 with open(self.import_csv_cache_path(), "rb") as f: 1472 cache = pickle.load(f) 1473 except: 1474 pass 1475 date_formats = [ 1476 "%Y-%m-%d %H:%M:%S", 1477 "%Y-%m-%dT%H:%M:%S", 1478 "%Y-%m-%dT%H%M%S", 1479 "%Y-%m-%d", 1480 ] 1481 created, found, bad = 0, 0, {} 1482 data: list[tuple] = [] 1483 with open(path, newline='', encoding="utf-8") as f: 1484 i = 0 1485 for row in csv.reader(f, delimiter=','): 1486 i += 1 1487 hashed = hash(tuple(row)) 1488 if hashed in cache: 1489 found += 1 1490 continue 1491 account = row[0] 1492 desc = row[1] 1493 value = float(row[2]) 1494 rate = 1.0 1495 if row[4:5]: # Empty list if index is out of range 1496 rate = float(row[4]) 1497 date: int = 0 1498 for time_format in date_formats: 1499 try: 1500 date = self.time(datetime.datetime.strptime(row[3], time_format)) 1501 break 1502 except: 1503 pass 1504 # TODO: not allowed for negative dates 1505 if date == 0 or value == 0: 1506 bad[i] = row 1507 continue 1508 if date in data: 1509 print('import_csv-duplicated(time)', date) 1510 continue 1511 data.append((date, value, desc, account, rate, hashed)) 1512 1513 if debug: 1514 print('import_csv', len(data)) 1515 for row in sorted(data, key=lambda x: x[0]): 1516 (date, value, desc, account, rate, hashed) = row 1517 if rate > 1: 1518 self.exchange(account, created=date, rate=rate) 1519 if value > 0: 1520 self.track(value, desc, account, True, date) 1521 elif value < 0: 1522 self.sub(-value, desc, account, date) 1523 created += 1 1524 cache.append(hashed) 1525 with open(self.import_csv_cache_path(), "wb") as f: 1526 pickle.dump(cache, f) 1527 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
1533 @staticmethod 1534 def duration_from_nanoseconds(ns: int) -> tuple: 1535 """ 1536 REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106 1537 Convert NanoSeconds to Human Readable Time Format. 1538 A NanoSeconds is a unit of time in the International System of Units (SI) equal 1539 to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second. 1540 Its symbol is μs, sometimes simplified to us when Unicode is not available. 1541 A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond. 1542 1543 INPUT : ms (AKA: MilliSeconds) 1544 OUTPUT: tuple(string time_lapsed, string spoken_time) like format. 1545 OUTPUT Variables: time_lapsed, spoken_time 1546 1547 Example Input: duration_from_nanoseconds(ns) 1548 **"Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds"** 1549 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') 1550 duration_from_nanoseconds(1234567890123456789012) 1551 """ 1552 us, ns = divmod(ns, 1000) 1553 ms, us = divmod(us, 1000) 1554 s, ms = divmod(ms, 1000) 1555 m, s = divmod(s, 60) 1556 h, m = divmod(m, 60) 1557 d, h = divmod(h, 24) 1558 y, d = divmod(d, 365) 1559 c, y = divmod(y, 100) 1560 n, c = divmod(c, 10) 1561 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}" 1562 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" 1563 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)
1565 @staticmethod 1566 def day_to_time(day: int, month: int = 6, year: int = 2024) -> int: # افتراض أن الشهر هو يونيو والسنة 2024 1567 """ 1568 Convert a specific day, month, and year into a timestamp. 1569 1570 Parameters: 1571 day (int): The day of the month. 1572 month (int): The month of the year. Default is 6 (June). 1573 year (int): The year. Default is 2024. 1574 1575 Returns: 1576 int: The timestamp representing the given day, month, and year. 1577 1578 Note: 1579 This method assumes the default month and year if not provided. 1580 """ 1581 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.
1583 @staticmethod 1584 def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime: 1585 """ 1586 Generate a random date between two given dates. 1587 1588 Parameters: 1589 start_date (datetime.datetime): The start date from which to generate a random date. 1590 end_date (datetime.datetime): The end date until which to generate a random date. 1591 1592 Returns: 1593 datetime.datetime: A random date between the start_date and end_date. 1594 """ 1595 time_between_dates = end_date - start_date 1596 days_between_dates = time_between_dates.days 1597 random_number_of_days = random.randrange(days_between_dates) 1598 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.
1600 @staticmethod 1601 def generate_random_csv_file(path: str = "data.csv", count: int = 1000, with_rate: bool = False, 1602 debug: bool = False) -> int: 1603 """ 1604 Generate a random CSV file with specified parameters. 1605 1606 Parameters: 1607 path (str): The path where the CSV file will be saved. Default is "data.csv". 1608 count (int): The number of rows to generate in the CSV file. Default is 1000. 1609 with_rate (bool): If True, a random rate between 1.2% and 12% is added. Default is False. 1610 debug (bool): A flag indicating whether to print debug information. 1611 1612 Returns: 1613 None. The function generates a CSV file at the specified path with the given count of rows. 1614 Each row contains a randomly generated account, description, value, and date. 1615 The value is randomly generated between 1000 and 100000, 1616 and the date is randomly generated between 1950-01-01 and 2023-12-31. 1617 If the row number is not divisible by 13, the value is multiplied by -1. 1618 """ 1619 i = 0 1620 with open(path, "w", newline="") as csvfile: 1621 writer = csv.writer(csvfile) 1622 for i in range(count): 1623 account = f"acc-{random.randint(1, 1000)}" 1624 desc = f"Some text {random.randint(1, 1000)}" 1625 value = random.randint(1000, 100000) 1626 date = ZakatTracker.generate_random_date(datetime.datetime(1950, 1, 1), 1627 datetime.datetime(2023, 12, 31)).strftime("%Y-%m-%d %H:%M:%S") 1628 if not i % 13 == 0: 1629 value *= -1 1630 row = [account, desc, value, date] 1631 if with_rate: 1632 rate = random.randint(1, 100) * 0.12 1633 if debug: 1634 print('before-append', row) 1635 row.append(rate) 1636 if debug: 1637 print('after-append', row) 1638 writer.writerow(row) 1639 i = i + 1 1640 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.
1642 @staticmethod 1643 def create_random_list(max_sum, min_value=0, max_value=10): 1644 """ 1645 Creates a list of random integers whose sum does not exceed the specified maximum. 1646 1647 Args: 1648 max_sum: The maximum allowed sum of the list elements. 1649 min_value: The minimum possible value for an element (inclusive). 1650 max_value: The maximum possible value for an element (inclusive). 1651 1652 Returns: 1653 A list of random integers. 1654 """ 1655 result = [] 1656 current_sum = 0 1657 1658 while current_sum < max_sum: 1659 # Calculate the remaining space for the next element 1660 remaining_sum = max_sum - current_sum 1661 # Determine the maximum possible value for the next element 1662 next_max_value = min(remaining_sum, max_value) 1663 # Generate a random element within the allowed range 1664 next_element = random.randint(min_value, next_max_value) 1665 result.append(next_element) 1666 current_sum += next_element 1667 1668 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.
1813 def test(self, debug: bool = False) -> bool: 1814 1815 try: 1816 1817 assert self._history() 1818 1819 # Not allowed for duplicate transactions in the same account and time 1820 1821 created = ZakatTracker.time() 1822 self.track(100, 'test-1', 'same', True, created) 1823 failed = False 1824 try: 1825 self.track(50, 'test-1', 'same', True, created) 1826 except: 1827 failed = True 1828 assert failed is True 1829 1830 self.reset() 1831 1832 # Same account transfer 1833 for x in [1, 'a', True, 1.8, None]: 1834 failed = False 1835 try: 1836 self.transfer(1, x, x, 'same-account', debug=debug) 1837 except: 1838 failed = True 1839 assert failed is True 1840 1841 # Always preserve box age during transfer 1842 1843 series: list[tuple] = [ 1844 (30, 4), 1845 (60, 3), 1846 (90, 2), 1847 ] 1848 case = { 1849 30: { 1850 'series': series, 1851 'rest': 150, 1852 }, 1853 60: { 1854 'series': series, 1855 'rest': 120, 1856 }, 1857 90: { 1858 'series': series, 1859 'rest': 90, 1860 }, 1861 180: { 1862 'series': series, 1863 'rest': 0, 1864 }, 1865 270: { 1866 'series': series, 1867 'rest': -90, 1868 }, 1869 360: { 1870 'series': series, 1871 'rest': -180, 1872 }, 1873 } 1874 1875 selected_time = ZakatTracker.time() - ZakatTracker.TimeCycle() 1876 1877 for total in case: 1878 for x in case[total]['series']: 1879 self.track(x[0], f"test-{x} ages", 'ages', True, selected_time * x[1]) 1880 1881 refs = self.transfer(total, 'ages', 'future', 'Zakat Movement', debug=debug) 1882 1883 if debug: 1884 print('refs', refs) 1885 1886 ages_cache_balance = self.balance('ages') 1887 ages_fresh_balance = self.balance('ages', False) 1888 rest = case[total]['rest'] 1889 if debug: 1890 print('source', ages_cache_balance, ages_fresh_balance, rest) 1891 assert ages_cache_balance == rest 1892 assert ages_fresh_balance == rest 1893 1894 future_cache_balance = self.balance('future') 1895 future_fresh_balance = self.balance('future', False) 1896 if debug: 1897 print('target', future_cache_balance, future_fresh_balance, total) 1898 print('refs', refs) 1899 assert future_cache_balance == total 1900 assert future_fresh_balance == total 1901 1902 for ref in self._vault['account']['ages']['box']: 1903 ages_capital = self._vault['account']['ages']['box'][ref]['capital'] 1904 ages_rest = self._vault['account']['ages']['box'][ref]['rest'] 1905 future_capital = 0 1906 if ref in self._vault['account']['future']['box']: 1907 future_capital = self._vault['account']['future']['box'][ref]['capital'] 1908 future_rest = 0 1909 if ref in self._vault['account']['future']['box']: 1910 future_rest = self._vault['account']['future']['box'][ref]['rest'] 1911 if ages_capital != 0 and future_capital != 0 and future_rest != 0: 1912 if debug: 1913 print('================================================================') 1914 print('ages', ages_capital, ages_rest) 1915 print('future', future_capital, future_rest) 1916 if ages_rest == 0: 1917 assert ages_capital == future_capital 1918 elif ages_rest < 0: 1919 assert -ages_capital == future_capital 1920 elif ages_rest > 0: 1921 assert ages_capital == ages_rest + future_capital 1922 self.reset() 1923 assert len(self._vault['history']) == 0 1924 1925 assert self._history() 1926 assert self._history(False) is False 1927 assert self._history() is False 1928 assert self._history(True) 1929 assert self._history() 1930 1931 self._test_core(True, debug) 1932 self._test_core(False, debug) 1933 1934 transaction = [ 1935 ( 1936 20, 'wallet', 1, 800, 800, 800, 4, 5, 1937 -85, -85, -85, 6, 7, 1938 ), 1939 ( 1940 750, 'wallet', 'safe', 50, 50, 50, 4, 6, 1941 750, 750, 750, 1, 1, 1942 ), 1943 ( 1944 600, 'safe', 'bank', 150, 150, 150, 1, 2, 1945 600, 600, 600, 1, 1, 1946 ), 1947 ] 1948 for z in transaction: 1949 self.lock() 1950 x = z[1] 1951 y = z[2] 1952 self.transfer(z[0], x, y, 'test-transfer', debug=debug) 1953 assert self.balance(x) == z[3] 1954 xx = self.accounts()[x] 1955 assert xx == z[3] 1956 assert self.balance(x, False) == z[4] 1957 assert xx == z[4] 1958 1959 s = 0 1960 log = self._vault['account'][x]['log'] 1961 for i in log: 1962 s += log[i]['value'] 1963 if debug: 1964 print('s', s, 'z[5]', z[5]) 1965 assert s == z[5] 1966 1967 assert self.box_size(x) == z[6] 1968 assert self.log_size(x) == z[7] 1969 1970 yy = self.accounts()[y] 1971 assert self.balance(y) == z[8] 1972 assert yy == z[8] 1973 assert self.balance(y, False) == z[9] 1974 assert yy == z[9] 1975 1976 s = 0 1977 log = self._vault['account'][y]['log'] 1978 for i in log: 1979 s += log[i]['value'] 1980 assert s == z[10] 1981 1982 assert self.box_size(y) == z[11] 1983 assert self.log_size(y) == z[12] 1984 1985 if debug: 1986 pp().pprint(self.check(2.17)) 1987 1988 assert not self.nolock() 1989 history_count = len(self._vault['history']) 1990 if debug: 1991 print('history-count', history_count) 1992 assert history_count == 11 1993 assert not self.free(ZakatTracker.time()) 1994 assert self.free(self.lock()) 1995 assert self.nolock() 1996 assert len(self._vault['history']) == 11 1997 1998 # storage 1999 2000 _path = self.path('test.pickle') 2001 if os.path.exists(_path): 2002 os.remove(_path) 2003 self.save() 2004 assert os.path.getsize(_path) > 0 2005 self.reset() 2006 assert self.recall(False, debug) is False 2007 self.load() 2008 assert self._vault['account'] is not None 2009 2010 # recall 2011 2012 assert self.nolock() 2013 assert len(self._vault['history']) == 11 2014 assert self.recall(False, debug) is True 2015 assert len(self._vault['history']) == 10 2016 assert self.recall(False, debug) is True 2017 assert len(self._vault['history']) == 9 2018 2019 csv_count = 1000 2020 2021 for with_rate, path in { 2022 False: 'test-import_csv-no-exchange', 2023 True: 'test-import_csv-with-exchange', 2024 }.items(): 2025 2026 if debug: 2027 print('test_import_csv', with_rate, path) 2028 2029 # csv 2030 2031 csv_path = path + '.csv' 2032 if os.path.exists(csv_path): 2033 os.remove(csv_path) 2034 c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug) 2035 if debug: 2036 print('generate_random_csv_file', c) 2037 assert c == csv_count 2038 assert os.path.getsize(csv_path) > 0 2039 cache_path = self.import_csv_cache_path() 2040 if os.path.exists(cache_path): 2041 os.remove(cache_path) 2042 self.reset() 2043 (created, found, bad) = self.import_csv(csv_path, debug) 2044 bad_count = len(bad) 2045 if debug: 2046 print(f"csv-imported: ({created}, {found}, {bad_count}) = count({csv_count})") 2047 tmp_size = os.path.getsize(cache_path) 2048 assert tmp_size > 0 2049 assert created + found + bad_count == csv_count 2050 assert created == csv_count 2051 assert bad_count == 0 2052 (created_2, found_2, bad_2) = self.import_csv(csv_path) 2053 bad_2_count = len(bad_2) 2054 if debug: 2055 print(f"csv-imported: ({created_2}, {found_2}, {bad_2_count})") 2056 print(bad) 2057 assert tmp_size == os.path.getsize(cache_path) 2058 assert created_2 + found_2 + bad_2_count == csv_count 2059 assert created == found_2 2060 assert bad_count == bad_2_count 2061 assert found_2 == csv_count 2062 assert bad_2_count == 0 2063 assert created_2 == 0 2064 2065 # payment parts 2066 2067 positive_parts = self.build_payment_parts(100, positive_only=True) 2068 assert self.check_payment_parts(positive_parts) != 0 2069 assert self.check_payment_parts(positive_parts) != 0 2070 all_parts = self.build_payment_parts(300, positive_only=False) 2071 assert self.check_payment_parts(all_parts) != 0 2072 assert self.check_payment_parts(all_parts) != 0 2073 if debug: 2074 pp().pprint(positive_parts) 2075 pp().pprint(all_parts) 2076 # dynamic discount 2077 suite = [] 2078 count = 3 2079 for exceed in [False, True]: 2080 case = [] 2081 for parts in [positive_parts, all_parts]: 2082 part = parts.copy() 2083 demand = part['demand'] 2084 if debug: 2085 print(demand, part['total']) 2086 i = 0 2087 z = demand / count 2088 cp = { 2089 'account': {}, 2090 'demand': demand, 2091 'exceed': exceed, 2092 'total': part['total'], 2093 } 2094 j = '' 2095 for x, y in part['account'].items(): 2096 x_exchange = self.exchange(x) 2097 zz = self.exchange_calc(z, 1, x_exchange['rate']) 2098 if exceed and zz <= demand: 2099 i += 1 2100 y['part'] = zz 2101 if debug: 2102 print(exceed, y) 2103 cp['account'][x] = y 2104 case.append(y) 2105 elif not exceed and y['balance'] >= zz: 2106 i += 1 2107 y['part'] = zz 2108 if debug: 2109 print(exceed, y) 2110 cp['account'][x] = y 2111 case.append(y) 2112 j = x 2113 if i >= count: 2114 break 2115 if len(cp['account'][j]) > 0: 2116 suite.append(cp) 2117 if debug: 2118 print('suite', len(suite)) 2119 for case in suite: 2120 if debug: 2121 print(case) 2122 result = self.check_payment_parts(case) 2123 if debug: 2124 print('check_payment_parts', result, f'exceed: {exceed}') 2125 assert result == 0 2126 2127 report = self.check(2.17, None, debug) 2128 (valid, brief, plan) = report 2129 if debug: 2130 print('valid', valid) 2131 assert self.zakat(report, parts=suite, debug=debug) 2132 assert self.save(path + '.pickle') 2133 assert self.export_json(path + '.json') 2134 2135 # exchange 2136 2137 self.exchange("cash", 25, 3.75, "2024-06-25") 2138 self.exchange("cash", 22, 3.73, "2024-06-22") 2139 self.exchange("cash", 15, 3.69, "2024-06-15") 2140 self.exchange("cash", 10, 3.66) 2141 2142 for i in range(1, 30): 2143 rate, description = self.exchange("cash", i).values() 2144 if debug: 2145 print(i, rate, description) 2146 if i < 10: 2147 assert rate == 1 2148 assert description is None 2149 elif i == 10: 2150 assert rate == 3.66 2151 assert description is None 2152 elif i < 15: 2153 assert rate == 3.66 2154 assert description is None 2155 elif i == 15: 2156 assert rate == 3.69 2157 assert description is not None 2158 elif i < 22: 2159 assert rate == 3.69 2160 assert description is not None 2161 elif i == 22: 2162 assert rate == 3.73 2163 assert description is not None 2164 elif i >= 25: 2165 assert rate == 3.75 2166 assert description is not None 2167 rate, description = self.exchange("bank", i).values() 2168 if debug: 2169 print(i, rate, description) 2170 assert rate == 1 2171 assert description is None 2172 2173 assert len(self._vault['exchange']) > 0 2174 assert len(self.exchanges()) > 0 2175 self._vault['exchange'].clear() 2176 assert len(self._vault['exchange']) == 0 2177 assert len(self.exchanges()) == 0 2178 2179 # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية 2180 self.exchange("cash", ZakatTracker.day_to_time(25), 3.75, "2024-06-25") 2181 self.exchange("cash", ZakatTracker.day_to_time(22), 3.73, "2024-06-22") 2182 self.exchange("cash", ZakatTracker.day_to_time(15), 3.69, "2024-06-15") 2183 self.exchange("cash", ZakatTracker.day_to_time(10), 3.66) 2184 2185 for i in [x * 0.12 for x in range(-15, 21)]: 2186 if i <= 0: 2187 assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) == 0 2188 else: 2189 assert len(self.exchange("test", ZakatTracker.time(), i, f"range({i})")) > 0 2190 2191 # اختبار النتائج باستخدام التواريخ بالنانو ثانية 2192 for i in range(1, 31): 2193 timestamp_ns = ZakatTracker.day_to_time(i) 2194 rate, description = self.exchange("cash", timestamp_ns).values() 2195 if debug: 2196 print(i, rate, description) 2197 if i < 10: 2198 assert rate == 1 2199 assert description is None 2200 elif i == 10: 2201 assert rate == 3.66 2202 assert description is None 2203 elif i < 15: 2204 assert rate == 3.66 2205 assert description is None 2206 elif i == 15: 2207 assert rate == 3.69 2208 assert description is not None 2209 elif i < 22: 2210 assert rate == 3.69 2211 assert description is not None 2212 elif i == 22: 2213 assert rate == 3.73 2214 assert description is not None 2215 elif i >= 25: 2216 assert rate == 3.75 2217 assert description is not None 2218 rate, description = self.exchange("bank", i).values() 2219 if debug: 2220 print(i, rate, description) 2221 assert rate == 1 2222 assert description is None 2223 2224 assert self.export_json("1000-transactions-test.json") 2225 assert self.save("1000-transactions-test.pickle") 2226 2227 self.reset() 2228 2229 # test transfer between accounts with different exchange rate 2230 2231 a_SAR = "Bank (SAR)" 2232 b_USD = "Bank (USD)" 2233 c_SAR = "Safe (SAR)" 2234 # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer 2235 for case in [ 2236 (0, a_SAR, "SAR Gift", 1000, 1000), 2237 (1, a_SAR, 1), 2238 (0, b_USD, "USD Gift", 500, 500), 2239 (1, b_USD, 1), 2240 (2, b_USD, 3.75), 2241 (1, b_USD, 3.75), 2242 (3, 100, b_USD, a_SAR, "100 USD -> SAR", 400, 1375), 2243 (0, c_SAR, "Salary", 750, 750), 2244 (3, 375, c_SAR, b_USD, "375 SAR -> USD", 375, 500), 2245 (3, 3.75, a_SAR, b_USD, "3.75 SAR -> USD", 1371.25, 501), 2246 ]: 2247 match (case[0]): 2248 case 0: # track 2249 _, account, desc, x, balance = case 2250 self.track(value=x, desc=desc, account=account, debug=debug) 2251 2252 cached_value = self.balance(account, cached=True) 2253 fresh_value = self.balance(account, cached=False) 2254 if debug: 2255 print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value) 2256 assert cached_value == balance 2257 assert fresh_value == balance 2258 case 1: # check-exchange 2259 _, account, expected_rate = case 2260 t_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug) 2261 if debug: 2262 print('t-exchange', t_exchange) 2263 assert t_exchange['rate'] == expected_rate 2264 case 2: # do-exchange 2265 _, account, rate = case 2266 self.exchange(account, rate=rate, debug=debug) 2267 b_exchange = self.exchange(account, created=ZakatTracker.time(), debug=debug) 2268 if debug: 2269 print('b-exchange', b_exchange) 2270 assert b_exchange['rate'] == rate 2271 case 3: # transfer 2272 _, x, a, b, desc, a_balance, b_balance = case 2273 self.transfer(x, a, b, desc, debug=debug) 2274 2275 cached_value = self.balance(a, cached=True) 2276 fresh_value = self.balance(a, cached=False) 2277 if debug: 2278 print('account', a, 'cached_value', cached_value, 'fresh_value', fresh_value) 2279 assert cached_value == a_balance 2280 assert fresh_value == a_balance 2281 2282 cached_value = self.balance(b, cached=True) 2283 fresh_value = self.balance(b, cached=False) 2284 if debug: 2285 print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value) 2286 assert cached_value == b_balance 2287 assert fresh_value == b_balance 2288 2289 # Transfer all in many chunks randomly from B to A 2290 a_SAR_balance = 1371.25 2291 b_USD_balance = 501 2292 b_USD_exchange = self.exchange(b_USD) 2293 amounts = ZakatTracker.create_random_list(b_USD_balance) 2294 if debug: 2295 print('amounts', amounts) 2296 i = 0 2297 for x in amounts: 2298 if debug: 2299 print(f'{i} - transfer-with-exchange({x})') 2300 self.transfer(x, b_USD, a_SAR, f"{x} USD -> SAR", debug=debug) 2301 2302 b_USD_balance -= x 2303 cached_value = self.balance(b_USD, cached=True) 2304 fresh_value = self.balance(b_USD, cached=False) 2305 if debug: 2306 print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', 2307 b_USD_balance) 2308 assert cached_value == b_USD_balance 2309 assert fresh_value == b_USD_balance 2310 2311 a_SAR_balance += x * b_USD_exchange['rate'] 2312 cached_value = self.balance(a_SAR, cached=True) 2313 fresh_value = self.balance(a_SAR, cached=False) 2314 if debug: 2315 print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', 2316 a_SAR_balance, 'rate', b_USD_exchange['rate']) 2317 assert cached_value == a_SAR_balance 2318 assert fresh_value == a_SAR_balance 2319 i += 1 2320 2321 # Transfer all in many chunks randomly from C to A 2322 c_SAR_balance = 375 2323 amounts = ZakatTracker.create_random_list(c_SAR_balance) 2324 if debug: 2325 print('amounts', amounts) 2326 i = 0 2327 for x in amounts: 2328 if debug: 2329 print(f'{i} - transfer-with-exchange({x})') 2330 self.transfer(x, c_SAR, a_SAR, f"{x} SAR -> a_SAR", debug=debug) 2331 2332 c_SAR_balance -= x 2333 cached_value = self.balance(c_SAR, cached=True) 2334 fresh_value = self.balance(c_SAR, cached=False) 2335 if debug: 2336 print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted', 2337 c_SAR_balance) 2338 assert cached_value == c_SAR_balance 2339 assert fresh_value == c_SAR_balance 2340 2341 a_SAR_balance += x 2342 cached_value = self.balance(a_SAR, cached=True) 2343 fresh_value = self.balance(a_SAR, cached=False) 2344 if debug: 2345 print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected', 2346 a_SAR_balance) 2347 assert cached_value == a_SAR_balance 2348 assert fresh_value == a_SAR_balance 2349 i += 1 2350 2351 assert self.export_json("accounts-transfer-with-exchange-rates.json") 2352 assert self.save("accounts-transfer-with-exchange-rates.pickle") 2353 2354 # check & zakat with exchange rates for many cycles 2355 2356 for rate, values in { 2357 1: { 2358 'in': [1000, 2000, 10000], 2359 'exchanged': [1000, 2000, 10000], 2360 'out': [25, 50, 731.40625], 2361 }, 2362 3.75: { 2363 'in': [200, 1000, 5000], 2364 'exchanged': [750, 3750, 18750], 2365 'out': [18.75, 93.75, 1371.38671875], 2366 }, 2367 }.items(): 2368 a, b, c = values['in'] 2369 m, n, o = values['exchanged'] 2370 x, y, z = values['out'] 2371 if debug: 2372 print('rate', rate, 'values', values) 2373 for case in [ 2374 (a, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [ 2375 {'safe': {0: {'below_nisab': x}}}, 2376 ], False, m), 2377 (b, 'safe', ZakatTracker.time() - ZakatTracker.TimeCycle(), [ 2378 {'safe': {0: {'count': 1, 'total': y}}}, 2379 ], True, n), 2380 (c, 'cave', ZakatTracker.time() - (ZakatTracker.TimeCycle() * 3), [ 2381 {'cave': {0: {'count': 3, 'total': z}}}, 2382 ], True, o), 2383 ]: 2384 if debug: 2385 print(f"############# check(rate: {rate}) #############") 2386 self.reset() 2387 self.exchange(account=case[1], created=case[2], rate=rate) 2388 self.track(value=case[0], desc='test-check', account=case[1], logging=True, created=case[2]) 2389 2390 # assert self.nolock() 2391 # history_size = len(self._vault['history']) 2392 # print('history_size', history_size) 2393 # assert history_size == 2 2394 assert self.lock() 2395 assert not self.nolock() 2396 report = self.check(2.17, None, debug) 2397 (valid, brief, plan) = report 2398 assert valid == case[4] 2399 if debug: 2400 print('brief', brief) 2401 assert case[5] == brief[0] 2402 assert case[5] == brief[1] 2403 2404 if debug: 2405 pp().pprint(plan) 2406 2407 for x in plan: 2408 assert case[1] == x 2409 if 'total' in case[3][0][x][0].keys(): 2410 assert case[3][0][x][0]['total'] == brief[2] 2411 assert plan[x][0]['total'] == case[3][0][x][0]['total'] 2412 assert plan[x][0]['count'] == case[3][0][x][0]['count'] 2413 else: 2414 assert plan[x][0]['below_nisab'] == case[3][0][x][0]['below_nisab'] 2415 if debug: 2416 pp().pprint(report) 2417 result = self.zakat(report, debug=debug) 2418 if debug: 2419 print('zakat-result', result, case[4]) 2420 assert result == case[4] 2421 report = self.check(2.17, None, debug) 2422 (valid, brief, plan) = report 2423 assert valid is False 2424 2425 history_size = len(self._vault['history']) 2426 if debug: 2427 print('history_size', history_size) 2428 assert history_size == 3 2429 assert not self.nolock() 2430 assert self.recall(False, debug) is False 2431 self.free(self.lock()) 2432 assert self.nolock() 2433 for i in range(3, 0, -1): 2434 history_size = len(self._vault['history']) 2435 if debug: 2436 print('history_size', history_size) 2437 assert history_size == i 2438 assert self.recall(False, debug) is True 2439 2440 assert self.nolock() 2441 2442 assert self.recall(False, debug) is False 2443 history_size = len(self._vault['history']) 2444 if debug: 2445 print('history_size', history_size) 2446 assert history_size == 0 2447 2448 assert len(self._vault['account']) == 0 2449 assert len(self._vault['history']) == 0 2450 assert len(self._vault['report']) == 0 2451 assert self.nolock() 2452 return True 2453 except: 2454 # pp().pprint(self._vault) 2455 assert self.export_json("test-snapshot.json") 2456 assert self.save("test-snapshot.pickle") 2457 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}")