zakat
xxx
_____ _ _ _____ _
|__ /__ _| | ____ _| |_ |_ _| __ __ _ ___| | _____ _ __
/ // _| |/ / _
| __| | || '__/ _` |/ __| |/ / _ \ '__|
/ /| (_| | < (_| | |_ | || | | (_| | (__| < __/ |
/______,_|_|___,_|__| |_||_| __,_|___|_|____|_|
"رَبَّنَا افْتَحْ بَيْنَنَا وَبَيْنَ قَوْمِنَا بِالْحَقِّ وَأَنتَ خَيْرُ الْفَاتِحِينَ (89)" -- سورة الأعراف ... Never Trust, Always Verify ...
This module provides the ZakatTracker class for tracking and calculating Zakat.
1""" 2 _____ _ _ _____ _ 3|__ /__ _| | ____ _| |_ |_ _| __ __ _ ___| | _____ _ __ 4 / // _` | |/ / _` | __| | || '__/ _` |/ __| |/ / _ \ '__| 5 / /| (_| | < (_| | |_ | || | | (_| | (__| < __/ | 6/____\__,_|_|\_\__,_|\__| |_||_| \__,_|\___|_|\_\___|_| 7 8"رَبَّنَا افْتَحْ بَيْنَنَا وَبَيْنَ قَوْمِنَا بِالْحَقِّ وَأَنتَ خَيْرُ الْفَاتِحِينَ (89)" -- سورة الأعراف 9... Never Trust, Always Verify ... 10 11This module provides the ZakatTracker class for tracking and calculating Zakat. 12""" 13# Importing necessary classes and functions from the main module 14from .zakat_tracker import ( 15 ZakatTracker, 16 Action, 17 JSONEncoder, 18 MathOperation, 19) 20 21# Version information for the module 22__version__ = ZakatTracker.Version() 23__all__ = [ 24 "ZakatTracker", 25 "Action", 26 "JSONEncoder", 27 "MathOperation", 28]
89class ZakatTracker: 90 """ 91 A class for tracking and calculating Zakat. 92 93 This class provides functionalities for recording transactions, calculating Zakat due, 94 and managing account balances. It also offers features like importing transactions from 95 CSV files, exporting data to JSON format, and saving/loading the tracker state. 96 97 The `ZakatTracker` class is designed to handle both positive and negative transactions, 98 allowing for flexible tracking of financial activities related to Zakat. It also supports 99 the concept of a "nisab" (minimum threshold for Zakat) and a "haul" (complete one year for Transaction) can calculate Zakat due 100 based on the current silver price. 101 102 The class uses a pickle file as its database to persist the tracker state, 103 ensuring data integrity across sessions. It also provides options for enabling or 104 disabling history tracking, allowing users to choose their preferred level of detail. 105 106 In addition, the `ZakatTracker` class includes various helper methods like 107 `time`, `time_to_datetime`, `lock`, `free`, `recall`, `export_json`, 108 and more. These methods provide additional functionalities and flexibility 109 for interacting with and managing the Zakat tracker. 110 111 Attributes: 112 ZakatCut (function): A function to calculate the Zakat percentage. 113 TimeCycle (function): A function to determine the time cycle for Zakat. 114 Nisab (function): A function to calculate the Nisab based on the silver price. 115 Version (str): The version of the ZakatTracker class. 116 117 Data Structure: 118 The ZakatTracker class utilizes a nested dictionary structure called "_vault" to store and manage data. 119 120 _vault (dict): 121 - account (dict): 122 - {account_number} (dict): 123 - balance (int): The current balance of the account. 124 - box (dict): A dictionary storing transaction details. 125 - {timestamp} (dict): 126 - capital (int): The initial amount of the transaction. 127 - count (int): The number of times Zakat has been calculated for this transaction. 128 - last (int): The timestamp of the last Zakat calculation. 129 - rest (int): The remaining amount after Zakat deductions and withdrawal. 130 - total (int): The total Zakat deducted from this transaction. 131 - count (int): The total number of transactions for the account. 132 - log (dict): A dictionary storing transaction logs. 133 - {timestamp} (dict): 134 - value (int): The transaction amount (positive or negative). 135 - desc (str): The description of the transaction. 136 - file (dict): A dictionary storing file references associated with the transaction. 137 - zakatable (bool): Indicates whether the account is subject to Zakat. 138 - history (dict): 139 - {timestamp} (list): A list of dictionaries storing the history of actions performed. 140 - {action_dict} (dict): 141 - action (Action): The type of action (CREATE, TRACK, LOG, SUB, ADD_FILE, REMOVE_FILE, REPORT, ZAKAT). 142 - account (str): The account number associated with the action. 143 - ref (int): The reference number of the transaction. 144 - file (int): The reference number of the file (if applicable). 145 - key (str): The key associated with the action (e.g., 'rest', 'total'). 146 - value (int): The value associated with the action. 147 - math (MathOperation): The mathematical operation performed (if applicable). 148 - lock (int or None): The timestamp indicating the current lock status (None if not locked). 149 - report (dict): 150 - {timestamp} (tuple): A tuple storing Zakat report details. 151 152 """ 153 154 # Hybrid Constants 155 ZakatCut = lambda x: 0.025*x # Zakat Cut in one Lunar Year 156 TimeCycle = lambda : int(60*60*24*354.367056*1e9) # Lunar Year in nanoseconds 157 Nisab = lambda x: 585*x # Silver Price in Local currency value 158 Version = lambda : '0.2.2' 159 160 def __init__(self, db_path: str = "zakat.pickle", history_mode: bool = True): 161 """ 162 Initialize ZakatTracker with database path and history mode. 163 164 Parameters: 165 db_path (str): The path to the database file. Default is "zakat.pickle". 166 history_mode (bool): The mode for tracking history. Default is True. 167 168 Returns: 169 None 170 """ 171 self.reset() 172 self._history(history_mode) 173 self.path(db_path) 174 self.load() 175 176 def path(self, _path: str = None) -> str: 177 """ 178 Set or get the database path. 179 180 Parameters: 181 _path (str): The path to the database file. If not provided, it returns the current path. 182 183 Returns: 184 str: The current database path. 185 """ 186 if _path is not None: 187 self._vault_path = _path 188 return self._vault_path 189 190 def _history(self, status: bool = None) -> bool: 191 """ 192 Enable or disable history tracking. 193 194 Parameters: 195 status (bool): The status of history tracking. Default is True. 196 197 Returns: 198 None 199 """ 200 if status is not None: 201 self._history_mode = status 202 return self._history_mode 203 204 def reset(self) -> None: 205 """ 206 Reset the internal data structure to its initial state. 207 208 Parameters: 209 None 210 211 Returns: 212 None 213 """ 214 self._vault = {} 215 self._vault['account'] = {} 216 self._vault['history'] = {} 217 self._vault['lock'] = None 218 self._vault['report'] = {} 219 220 @staticmethod 221 def time(now: datetime = None) -> int: 222 """ 223 Generates a timestamp based on the provided datetime object or the current datetime. 224 225 Parameters: 226 now (datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used. 227 228 Returns: 229 int: The timestamp in positive nanoseconds since the Unix epoch (January 1, 1970), before 1970 will return in negative until 1000AD. 230 """ 231 if now is None: 232 now = datetime.datetime.now() 233 ordinal_day = now.toordinal() 234 ns_in_day = (now - now.replace(hour=0, minute=0, second=0, microsecond=0)).total_seconds() * 10**9 235 return int((ordinal_day - 719_163) * 86_400_000_000_000 + ns_in_day) 236 237 @staticmethod 238 def time_to_datetime(ordinal_ns: int) -> datetime: 239 """ 240 Converts an ordinal number (number of days since 1000-01-01) to a datetime object. 241 242 Parameters: 243 ordinal_ns (int): The ordinal number of days since 1000-01-01. 244 245 Returns: 246 datetime: The corresponding datetime object. 247 """ 248 ordinal_day = ordinal_ns // 86_400_000_000_000 + 719_163 249 ns_in_day = ordinal_ns % 86_400_000_000_000 250 d = datetime.datetime.fromordinal(ordinal_day) 251 t = datetime.timedelta(seconds=ns_in_day // 10**9) 252 return datetime.datetime.combine(d, datetime.time()) + t 253 254 def _step(self, action: Action = None, account = None, ref: int = None, file: int = None, value: int = None, key: str = None, math_operation: MathOperation = None) -> int: 255 """ 256 This method is responsible for recording the actions performed on the ZakatTracker. 257 258 Parameters: 259 - action (Action): The type of action performed. 260 - account (str): The account number on which the action was performed. 261 - ref (int): The reference number of the action. 262 - file (int): The file reference number of the action. 263 - value (int): The value associated with the action. 264 - key (str): The key associated with the action. 265 - math_operation (MathOperation): The mathematical operation performed during the action. 266 267 Returns: 268 - int: The lock time of the recorded action. If no lock was performed, it returns 0. 269 """ 270 if not self._history(): 271 return 0 272 _lock = self._vault['lock'] 273 if self.nolock(): 274 _lock = self._vault['lock'] = ZakatTracker.time() 275 self._vault['history'][_lock] = [] 276 if action is None: 277 return _lock 278 self._vault['history'][_lock].append({ 279 'action': action, 280 'account': account, 281 'ref': ref, 282 'file': file, 283 'key': key, 284 'value': value, 285 'math': math_operation, 286 }) 287 288 def nolock(self) -> bool: 289 """ 290 Check if the vault lock is currently not set. 291 292 :return: True if the vault lock is not set, False otherwise. 293 """ 294 return self._vault['lock'] is None 295 296 def lock(self) -> int: 297 """ 298 Acquires a lock on the ZakatTracker instance. 299 300 Returns: 301 int: The lock ID. This ID can be used to release the lock later. 302 """ 303 return self._step() 304 305 def box(self) -> dict: 306 """ 307 Returns a copy of the internal vault dictionary. 308 309 This method is used to retrieve the current state of the ZakatTracker object. 310 It provides a snapshot of the internal data structure, allowing for further 311 processing or analysis. 312 313 :return: A copy of the internal vault dictionary. 314 """ 315 return self._vault.copy() 316 317 def steps(self) -> dict: 318 """ 319 Returns a copy of the history of steps taken in the ZakatTracker. 320 321 The history is a dictionary where each key is a unique identifier for a step, 322 and the corresponding value is a dictionary containing information about the step. 323 324 :return: A copy of the history of steps taken in the ZakatTracker. 325 """ 326 return self._vault['history'].copy() 327 328 def free(self, _lock: int, auto_save: bool = True) -> bool: 329 """ 330 Releases the lock on the database. 331 332 Parameters: 333 _lock (int): The lock ID to be released. 334 auto_save (bool): Whether to automatically save the database after releasing the lock. 335 336 Returns: 337 bool: True if the lock is successfully released and (optionally) saved, False otherwise. 338 """ 339 if _lock == self._vault['lock']: 340 self._vault['lock'] = None 341 if auto_save: 342 return self.save(self.path()) 343 return True 344 return False 345 346 def account_exists(self, account) -> bool: 347 """ 348 Check if the given account exists in the vault. 349 350 Parameters: 351 account (str): The account number to check. 352 353 Returns: 354 bool: True if the account exists, False otherwise. 355 """ 356 return account in self._vault['account'] 357 358 def box_size(self, account) -> int: 359 """ 360 Calculate the size of the box for a specific account. 361 362 Parameters: 363 account (str): The account number for which the box size needs to be calculated. 364 365 Returns: 366 int: The size of the box for the given account. If the account does not exist, -1 is returned. 367 """ 368 if self.account_exists(account): 369 return len(self._vault['account'][account]['box']) 370 return -1 371 372 def log_size(self, account) -> int: 373 """ 374 Get the size of the log for a specific account. 375 376 Parameters: 377 account (str): The account number for which the log size needs to be calculated. 378 379 Returns: 380 int: The size of the log for the given account. If the account does not exist, -1 is returned. 381 """ 382 if self.account_exists(account): 383 return len(self._vault['account'][account]['log']) 384 return -1 385 386 def recall(self, dry = True, debug = False) -> bool: 387 """ 388 Revert the last operation. 389 390 Parameters: 391 dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True. 392 debug (bool): If True, the function will print debug information. Default is False. 393 394 Returns: 395 bool: True if the operation was successful, False otherwise. 396 """ 397 if not self.nolock() or len(self._vault['history']) == 0: 398 return False 399 if len(self._vault['history']) <= 0: 400 return 401 ref = sorted(self._vault['history'].keys())[-1] 402 if debug: 403 print('recall', ref) 404 memory = self._vault['history'][ref] 405 if debug: 406 print(type(memory), 'memory', memory) 407 408 limit = len(memory) + 1 409 sub_positive_log_negative = 0 410 for i in range(-1, -limit, -1): 411 x = memory[i] 412 if debug: 413 print(type(x), x) 414 match x['action']: 415 case Action.CREATE: 416 if x['account'] is not None: 417 if self.account_exists(x['account']): 418 if debug: 419 print('account', self._vault['account'][x['account']]) 420 assert len(self._vault['account'][x['account']]['box']) == 0 421 assert self._vault['account'][x['account']]['balance'] == 0 422 assert self._vault['account'][x['account']]['count'] == 0 423 if dry: 424 continue 425 del self._vault['account'][x['account']] 426 427 case Action.TRACK: 428 if x['account'] is not None: 429 if self.account_exists(x['account']): 430 if dry: 431 continue 432 self._vault['account'][x['account']]['balance'] -= x['value'] 433 self._vault['account'][x['account']]['count'] -= 1 434 del self._vault['account'][x['account']]['box'][x['ref']] 435 436 case Action.LOG: 437 if x['account'] is not None: 438 if self.account_exists(x['account']): 439 if x['ref'] in self._vault['account'][x['account']]['log']: 440 if dry: 441 continue 442 if sub_positive_log_negative == -x['value']: 443 self._vault['account'][x['account']]['count'] -= 1 444 sub_positive_log_negative = 0 445 del self._vault['account'][x['account']]['log'][x['ref']] 446 447 case Action.SUB: 448 if x['account'] is not None: 449 if self.account_exists(x['account']): 450 if x['ref'] in self._vault['account'][x['account']]['box']: 451 if dry: 452 continue 453 self._vault['account'][x['account']]['box'][x['ref']]['rest'] += x['value'] 454 self._vault['account'][x['account']]['balance'] += x['value'] 455 sub_positive_log_negative = x['value'] 456 457 case Action.ADD_FILE: 458 if x['account'] is not None: 459 if self.account_exists(x['account']): 460 if x['ref'] in self._vault['account'][x['account']]['log']: 461 if x['file'] in self._vault['account'][x['account']]['log'][x['ref']]['file']: 462 if dry: 463 continue 464 del self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']] 465 466 case Action.REMOVE_FILE: 467 if x['account'] is not None: 468 if self.account_exists(x['account']): 469 if x['ref'] in self._vault['account'][x['account']]['log']: 470 if dry: 471 continue 472 self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']] = x['value'] 473 474 case Action.REPORT: 475 if x['ref'] in self._vault['report']: 476 if dry: 477 continue 478 del self._vault['report'][x['ref']] 479 480 case Action.ZAKAT: 481 if x['account'] is not None: 482 if self.account_exists(x['account']): 483 if x['ref'] in self._vault['account'][x['account']]['box']: 484 if x['key'] in self._vault['account'][x['account']]['box'][x['ref']]: 485 if dry: 486 continue 487 match x['math']: 488 case MathOperation.ADDITION: 489 self._vault['account'][x['account']]['box'][x['ref']][x['key']] -= x['value'] 490 case MathOperation.EQUAL: 491 self._vault['account'][x['account']]['box'][x['ref']][x['key']] = x['value'] 492 case MathOperation.SUBTRACTION: 493 self._vault['account'][x['account']]['box'][x['ref']][x['key']] += x['value'] 494 495 if not dry: 496 del self._vault['history'][ref] 497 return True 498 499 def track(self, value: int = 0, desc: str = '', account: str = 1, logging: bool = True, created: int = None, debug: bool = False) -> int: 500 """ 501 This function tracks a transaction for a specific account. 502 503 Parameters: 504 value (int): The value of the transaction. Default is 0. 505 desc (str): The description of the transaction. Default is an empty string. 506 account (str): The account for which the transaction is being tracked. Default is '1'. 507 logging (bool): Whether to log the transaction. Default is True. 508 created (int): The timestamp of the transaction. If not provided, it will be generated. Default is None. 509 debug (bool): Whether to print debug information. Default is False. 510 511 Returns: 512 int: The timestamp of the transaction. 513 514 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. 515 516 Raises: 517 ValueError: If the transction happend again in the same time. 518 """ 519 if created is None: 520 created = ZakatTracker.time() 521 _nolock = self.nolock(); self.lock() 522 if not self.account_exists(account): 523 if debug: 524 print(f"account {account} created") 525 self._vault['account'][account] = { 526 'balance': 0, 527 'box': {}, 528 'count': 0, 529 'log': {}, 530 'zakatable': True, 531 } 532 self._step(Action.CREATE, account) 533 if value == 0: 534 if _nolock: self.free(self.lock()) 535 return 0 536 if logging: 537 self._log(value, desc, account, created) 538 assert created not in self._vault['account'][account]['box'] 539 self._vault['account'][account]['box'][created] = { 540 'capital': value, 541 'count': 0, 542 'last': 0, 543 'rest': value, 544 'total': 0, 545 } 546 self._step(Action.TRACK, account, ref=created, value=value) 547 if _nolock: self.free(self.lock()) 548 return created 549 550 def _log(self, value: int, desc: str = '', account: str = 1, created: int = None) -> int: 551 """ 552 Log a transaction into the account's log. 553 554 Parameters: 555 value (int): The value of the transaction. 556 desc (str): The description of the transaction. 557 account (str): The account to log the transaction into. Default is '1'. 558 created (int): The timestamp of the transaction. If not provided, it will be generated. 559 560 Returns: 561 int: The timestamp of the logged transaction. 562 563 This method updates the account's balance, count, and log with the transaction details. 564 It also creates a step in the history of the transaction. 565 566 Raises: 567 ValueError: If the transction happend again in the same time. 568 """ 569 if created is None: 570 created = ZakatTracker.time() 571 self._vault['account'][account]['balance'] += value 572 self._vault['account'][account]['count'] += 1 573 assert created not in self._vault['account'][account]['log'] 574 self._vault['account'][account]['log'][created] = { 575 'value': value, 576 'desc': desc, 577 'file': {}, 578 } 579 self._step(Action.LOG, account, ref=created, value=value) 580 return created 581 582 def accounts(self) -> dict: 583 """ 584 Returns a dictionary containing account numbers as keys and their respective balances as values. 585 586 Parameters: 587 None 588 589 Returns: 590 dict: A dictionary where keys are account numbers and values are their respective balances. 591 """ 592 result = {} 593 for i in self._vault['account']: 594 result[i] = self._vault['account'][i]['balance'] 595 return result 596 597 def boxes(self, account) -> dict: 598 """ 599 Retrieve the boxes (transactions) associated with a specific account. 600 601 Parameters: 602 account (str): The account number for which to retrieve the boxes. 603 604 Returns: 605 dict: A dictionary containing the boxes associated with the given account. 606 If the account does not exist, an empty dictionary is returned. 607 """ 608 if self.account_exists(account): 609 return self._vault['account'][account]['box'] 610 return {} 611 612 def logs(self, account) -> dict: 613 """ 614 Retrieve the logs (transactions) associated with a specific account. 615 616 Parameters: 617 account (str): The account number for which to retrieve the logs. 618 619 Returns: 620 dict: A dictionary containing the logs associated with the given account. 621 If the account does not exist, an empty dictionary is returned. 622 """ 623 if self.account_exists(account): 624 return self._vault['account'][account]['log'] 625 return {} 626 627 def add_file(self, account: str, ref: int, path: str) -> int: 628 """ 629 Adds a file reference to a specific transaction log entry in the vault. 630 631 Parameters: 632 account (str): The account number associated with the transaction log. 633 ref (int): The reference to the transaction log entry. 634 path (str): The path of the file to be added. 635 636 Returns: 637 int: The reference of the added file. If the account or transaction log entry does not exist, returns 0. 638 """ 639 if self.account_exists(account): 640 if ref in self._vault['account'][account]['log']: 641 file_ref = ZakatTracker.time() 642 self._vault['account'][account]['log'][ref]['file'][file_ref] = path 643 _nolock = self.nolock(); self.lock() 644 self._step(Action.ADD_FILE, account, ref=ref, file=file_ref) 645 if _nolock: self.free(self.lock()) 646 return file_ref 647 return 0 648 649 def remove_file(self, account: str, ref: int, file_ref: int) -> bool: 650 """ 651 Removes a file reference from a specific transaction log entry in the vault. 652 653 Parameters: 654 account (str): The account number associated with the transaction log. 655 ref (int): The reference to the transaction log entry. 656 file_ref (int): The reference of the file to be removed. 657 658 Returns: 659 bool: True if the file reference is successfully removed, False otherwise. 660 """ 661 if self.account_exists(account): 662 if ref in self._vault['account'][account]['log']: 663 if file_ref in self._vault['account'][account]['log'][ref]['file']: 664 x = self._vault['account'][account]['log'][ref]['file'][file_ref] 665 del self._vault['account'][account]['log'][ref]['file'][file_ref] 666 _nolock = self.nolock(); self.lock() 667 self._step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x) 668 if _nolock: self.free(self.lock()) 669 return True 670 return False 671 672 def balance(self, account: str = 1, cached: bool = True) -> int: 673 """ 674 Calculate and return the balance of a specific account. 675 676 Parameters: 677 account (str): The account number. Default is '1'. 678 cached (bool): If True, use the cached balance. If False, calculate the balance from the box. Default is True. 679 680 Returns: 681 int: The balance of the account. 682 683 Note: 684 If cached is True, the function returns the cached balance. 685 If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items. 686 """ 687 if cached: 688 return self._vault['account'][account]['balance'] 689 x = 0 690 return [x := x + y['rest'] for y in self._vault['account'][account]['box'].values()][-1] 691 692 def zakatable(self, account, status: bool = None) -> bool: 693 """ 694 Check or set the zakatable status of a specific account. 695 696 Parameters: 697 account (str): The account number. 698 status (bool, optional): The new zakatable status. If not provided, the function will return the current status. 699 700 Returns: 701 bool: The current or updated zakatable status of the account. 702 703 Raises: 704 None 705 706 Example: 707 >>> tracker = ZakatTracker() 708 >>> tracker.zakatable('account1', True) # Set the zakatable status of 'account1' to True 709 True 710 >>> tracker.zakatable('account1') # Get the zakatable status of 'account1' 711 True 712 """ 713 if self.account_exists(account): 714 if status is None: 715 return self._vault['account'][account]['zakatable'] 716 self._vault['account'][account]['zakatable'] = status 717 return status 718 return False 719 720 def sub(self, x: int, desc: str = '', account: str = 1, created: int = None, debug: bool = False) -> int: 721 """ 722 Subtracts a specified amount from the account's balance. 723 724 Parameters: 725 x (int): The amount to subtract. 726 desc (str): The description of the transaction. 727 account (str): The account from which to subtract. 728 created (int): The timestamp of the transaction. 729 debug (bool): A flag to enable debug mode. 730 731 Returns: 732 int: The timestamp of the transaction. 733 734 If the amount to subtract is greater than the account's balance, 735 the remaining amount will be transferred to a new transaction with a negative value. 736 737 Raises: 738 ValueError: If the transction happend again in the same time. 739 """ 740 if x < 0: 741 return 742 if x == 0: 743 return self.track(x, '', account) 744 if created is None: 745 created = ZakatTracker.time() 746 _nolock = self.nolock(); self.lock() 747 self.track(0, '', account) 748 self._log(-x, desc, account, created) 749 ids = sorted(self._vault['account'][account]['box'].keys()) 750 limit = len(ids) + 1 751 target = x 752 if debug: 753 print('ids', ids) 754 ages = [] 755 for i in range(-1, -limit, -1): 756 if target == 0: 757 break 758 j = ids[i] 759 if debug: 760 print('i', i, 'j', j) 761 if self._vault['account'][account]['box'][j]['rest'] >= target: 762 self._vault['account'][account]['box'][j]['rest'] -= target 763 self._step(Action.SUB, account, ref=j, value=target) 764 ages.append((j, target)) 765 target = 0 766 break 767 elif self._vault['account'][account]['box'][j]['rest'] < target and self._vault['account'][account]['box'][j]['rest'] > 0: 768 chunk = self._vault['account'][account]['box'][j]['rest'] 769 target -= chunk 770 self._step(Action.SUB, account, ref=j, value=chunk) 771 ages.append((j, chunk)) 772 self._vault['account'][account]['box'][j]['rest'] = 0 773 if target > 0: 774 self.track(-target, desc, account, False, created) 775 ages.append((created, target)) 776 if _nolock: self.free(self.lock()) 777 return (created, ages) 778 779 def transfer(self, value: int, from_account: str, to_account: str, desc: str = '', created: int = None) -> list[int]: 780 """ 781 Transfers a specified value from one account to another. 782 783 Parameters: 784 value (int): The amount to be transferred. 785 from_account (str): The account from which the value will be transferred. 786 to_account (str): The account to which the value will be transferred. 787 desc (str, optional): A description for the transaction. Defaults to an empty string. 788 created (int, optional): The timestamp of the transaction. If not provided, the current timestamp will be used. 789 790 Returns: 791 list[int]: A list of timestamps corresponding to the transactions made during the transfer. 792 793 Raises: 794 ValueError: If the transction happend again in the same time. 795 """ 796 if created is None: 797 created = ZakatTracker.time() 798 (_, ages) = self.sub(value, desc, from_account, created) 799 times = [] 800 for age in ages: 801 y = self.track(age[1], desc, to_account, logging=True, created=age[0]) 802 times.append(y) 803 return times 804 805 def check(self, silver_gram_price: float, nisab: float = None, debug: bool = False, now: int = None, cycle: float = None) -> tuple: 806 """ 807 Check the eligibility for Zakat based on the given parameters. 808 809 Parameters: 810 silver_gram_price (float): The price of a gram of silver. 811 nisab (float): The minimum amount of wealth required for Zakat. If not provided, it will be calculated based on the silver_gram_price. 812 debug (bool): Flag to enable debug mode. 813 now (int): The current timestamp. If not provided, it will be calculated using ZakatTracker.time(). 814 cycle (float): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle(). 815 816 Returns: 817 tuple: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics, and a dictionary containing the Zakat plan. 818 """ 819 if now is None: 820 now = ZakatTracker.time() 821 if cycle is None: 822 cycle = ZakatTracker.TimeCycle() 823 if nisab is None: 824 nisab = ZakatTracker.Nisab(silver_gram_price) 825 plan = {} 826 below_nisab = 0 827 brief = [0, 0, 0] 828 valid = False 829 for x in self._vault['account']: 830 if not self._vault['account'][x]['zakatable']: 831 continue 832 _box = self._vault['account'][x]['box'] 833 limit = len(_box) + 1 834 ids = sorted(self._vault['account'][x]['box'].keys()) 835 for i in range(-1, -limit, -1): 836 j = ids[i] 837 if _box[j]['rest'] <= 0: 838 continue 839 brief[0] += _box[j]['rest'] 840 index = limit + i - 1 841 epoch = (now - j) / cycle 842 if debug: 843 print(f"Epoch: {epoch}", _box[j]) 844 if _box[j]['last'] > 0: 845 epoch = (now - _box[j]['last']) / cycle 846 if debug: 847 print(f"Epoch: {epoch}") 848 epoch = floor(epoch) 849 if debug: 850 print(f"Epoch: {epoch}", type(epoch), epoch == 0, 1-epoch, epoch) 851 if epoch == 0: 852 continue 853 if debug: 854 print("Epoch - PASSED") 855 brief[1] += _box[j]['rest'] 856 if _box[j]['rest'] >= nisab: 857 total = 0 858 for _ in range(epoch): 859 total += ZakatTracker.ZakatCut(_box[j]['rest'] - total) 860 if total > 0: 861 if x not in plan: 862 plan[x] = {} 863 valid = True 864 brief[2] += total 865 plan[x][index] = {'total': total, 'count': epoch} 866 else: 867 chunk = ZakatTracker.ZakatCut(_box[j]['rest']) 868 if chunk > 0: 869 if x not in plan: 870 plan[x] = {} 871 if j not in plan[x].keys(): 872 plan[x][index] = {} 873 below_nisab += _box[j]['rest'] 874 brief[2] += chunk 875 plan[x][index]['below_nisab'] = chunk 876 valid = valid or below_nisab >= nisab 877 if debug: 878 print(f"below_nisab({below_nisab}) >= nisab({nisab})") 879 return (valid, brief, plan) 880 881 def build_payment_parts(self, demand: float, positive_only: bool = True) -> dict: 882 """ 883 Build payment parts for the zakat distribution. 884 885 Parameters: 886 demand (float): The total demand for payment. 887 positive_only (bool): If True, only consider accounts with positive balance. Default is True. 888 889 Returns: 890 dict: A dictionary containing the payment parts for each account. The dictionary has the following structure: 891 { 892 'account': { 893 'account_id': {'balance': float, 'part': float}, 894 ... 895 }, 896 'exceed': bool, 897 'demand': float, 898 'total': float, 899 } 900 """ 901 total = 0 902 parts = { 903 'account': {}, 904 'exceed': False, 905 'demand': demand, 906 } 907 for x, y in self.accounts().items(): 908 if positive_only and y <= 0: 909 continue 910 total += y 911 parts['account'][x] = {'balance': y, 'part': 0} 912 parts['total'] = total 913 return parts 914 915 def check_payment_parts(self, parts: dict) -> int: 916 """ 917 Checks the validity of payment parts. 918 919 Parameters: 920 parts (dict): A dictionary containing payment parts information. 921 922 Returns: 923 int: Returns 0 if the payment parts are valid, otherwise returns the error code. 924 925 Error Codes: 926 1: 'demand', 'account', 'total', or 'exceed' key is missing in parts. 927 2: 'balance' or 'part' key is missing in parts['account'][x]. 928 3: 'part' value in parts['account'][x] is less than or equal to 0. 929 4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0. 930 5: 'part' value in parts['account'][x] is less than 0. 931 6: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value. 932 7: The sum of 'part' values in parts['account'] does not match with 'demand' value. 933 """ 934 for i in ['demand', 'account', 'total', 'exceed']: 935 if not i in parts: 936 return 1 937 exceed = parts['exceed'] 938 for x in parts['account']: 939 for j in ['balance', 'part']: 940 if not j in parts['account'][x]: 941 return 2 942 if parts['account'][x]['part'] <= 0: 943 return 3 944 if not exceed and parts['account'][x]['balance'] <= 0: 945 return 4 946 demand = parts['demand'] 947 z = 0 948 for _, y in parts['account'].items(): 949 if y['part'] < 0: 950 return 5 951 if not exceed and y['part'] > y['balance']: 952 return 6 953 z += y['part'] 954 if z != demand: 955 return 7 956 return 0 957 958 def zakat(self, report: tuple, parts: dict = None, debug: bool = False) -> bool: 959 """ 960 Perform Zakat calculation based on the given report and optional parts. 961 962 Parameters: 963 report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan. 964 parts (dict): A dictionary containing the payment parts for the zakat. 965 debug (bool): A flag indicating whether to print debug information. 966 967 Returns: 968 bool: True if the zakat calculation is successful, False otherwise. 969 """ 970 (valid, _, plan) = report 971 if not valid: 972 return valid 973 parts_exist = parts is not None 974 if parts_exist: 975 for part in parts: 976 if self.check_payment_parts(part) != 0: 977 return False 978 if debug: 979 print('######### zakat #######') 980 print('parts_exist', parts_exist) 981 _nolock = self.nolock(); self.lock() 982 report_time = ZakatTracker.time() 983 self._vault['report'][report_time] = report 984 self._step(Action.REPORT, ref=report_time) 985 created = ZakatTracker.time() 986 for x in plan: 987 if debug: 988 print(plan[x]) 989 print('-------------') 990 print(self._vault['account'][x]['box']) 991 ids = sorted(self._vault['account'][x]['box'].keys()) 992 if debug: 993 print('plan[x]', plan[x]) 994 for i in plan[x].keys(): 995 j = ids[i] 996 if debug: 997 print('i', i, 'j', j) 998 self._step(Action.ZAKAT, x, j, value=self._vault['account'][x]['box'][j]['last'], key='last', math_operation=MathOperation.EQUAL) 999 self._vault['account'][x]['box'][j]['last'] = created 1000 self._vault['account'][x]['box'][j]['total'] += plan[x][i]['total'] 1001 self._step(Action.ZAKAT, x, j, value=plan[x][i]['total'], key='total', math_operation=MathOperation.ADDITION) 1002 self._vault['account'][x]['box'][j]['count'] += plan[x][i]['count'] 1003 self._step(Action.ZAKAT, x, j, value=plan[x][i]['count'], key='count', math_operation=MathOperation.ADDITION) 1004 if not parts_exist: 1005 self._vault['account'][x]['box'][j]['rest'] -= plan[x][i]['total'] 1006 self._step(Action.ZAKAT, x, j, value=plan[x][i]['total'], key='rest', math_operation=MathOperation.SUBTRACTION) 1007 if parts_exist: 1008 for transaction in parts: 1009 for account, part in transaction['account'].items(): 1010 if debug: 1011 print('zakat-part', account, part['part']) 1012 self.sub(part['part'], 'zakat-part', account, debug=debug) 1013 if _nolock: self.free(self.lock()) 1014 return True 1015 1016 def export_json(self, path: str = "data.json") -> bool: 1017 """ 1018 Exports the current state of the ZakatTracker object to a JSON file. 1019 1020 Parameters: 1021 path (str): The path where the JSON file will be saved. Default is "data.json". 1022 1023 Returns: 1024 bool: True if the export is successful, False otherwise. 1025 1026 Raises: 1027 No specific exceptions are raised by this method. 1028 """ 1029 with open(path, "w") as file: 1030 json.dump(self._vault, file, indent=4, cls=JSONEncoder) 1031 return True 1032 return False 1033 1034 def save(self, path: str = None) -> bool: 1035 """ 1036 Save the current state of the ZakatTracker object to a pickle file. 1037 1038 Parameters: 1039 path (str): The path where the pickle file will be saved. If not provided, it will use the default path. 1040 1041 Returns: 1042 bool: True if the save operation is successful, False otherwise. 1043 """ 1044 if path is None: 1045 path = self.path() 1046 with open(path, "wb") as f: 1047 pickle.dump(self._vault, f) 1048 return True 1049 return False 1050 1051 def load(self, path: str = None) -> bool: 1052 """ 1053 Load the current state of the ZakatTracker object from a pickle file. 1054 1055 Parameters: 1056 path (str): The path where the pickle file is located. If not provided, it will use the default path. 1057 1058 Returns: 1059 bool: True if the load operation is successful, False otherwise. 1060 """ 1061 if path is None: 1062 path = self.path() 1063 if os.path.exists(path): 1064 with open(path, "rb") as f: 1065 self._vault = pickle.load(f) 1066 return True 1067 return False 1068 1069 def import_csv(self, path: str = 'file.csv') -> tuple: 1070 """ 1071 Import transactions from a CSV file. 1072 1073 Parameters: 1074 path (str): The path to the CSV file. Default is 'file.csv'. 1075 1076 Returns: 1077 tuple: A tuple containing the number of transactions created, the number of transactions found in the cache, and a dictionary of bad transactions. 1078 1079 The CSV file should have the following format: 1080 account, desc, value, date 1081 For example: 1082 safe-45, "Some text", 34872, 1988-06-30 00:00:00 1083 1084 The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system. 1085 """ 1086 cache = [] 1087 tmp = "tmp" 1088 try: 1089 with open(tmp, "rb") as f: 1090 cache = pickle.load(f) 1091 except: 1092 pass 1093 date_formats = [ 1094 "%Y-%m-%d %H:%M:%S", 1095 "%Y-%m-%dT%H:%M:%S", 1096 "%Y-%m-%dT%H%M%S", 1097 "%Y-%m-%d", 1098 ] 1099 created, found, bad = 0, 0, {} 1100 with open(path, newline='', encoding="utf-8") as f: 1101 i = 0 1102 for row in csv.reader(f, delimiter=','): 1103 i += 1 1104 hashed = hash(tuple(row)) 1105 if hashed in cache: 1106 found += 1 1107 continue 1108 account = row[0] 1109 desc = row[1] 1110 value = float(row[2]) 1111 date = 0 1112 for time_format in date_formats: 1113 try: 1114 date = self.time(datetime.datetime.strptime(row[3], time_format)) 1115 break 1116 except: 1117 pass 1118 # TODO: not allowed for negative dates 1119 if date == 0 or value == 0: 1120 bad[i] = row 1121 continue 1122 if value > 0: 1123 self.track(value, desc, account, True, date) 1124 elif value < 0: 1125 self.sub(-value, desc, account, date) 1126 created += 1 1127 cache.append(hashed) 1128 with open(tmp, "wb") as f: 1129 pickle.dump(cache, f) 1130 return (created, found, bad) 1131 1132 ######## 1133 # TESTS # 1134 ####### 1135 1136 @staticmethod 1137 def DurationFromNanoSeconds(ns: int) -> tuple: 1138 """REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106 1139 Convert NanoSeconds to Human Readable Time Format. 1140 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. 1141 Its symbol is μs, sometimes simplified to us when Unicode is not available. 1142 A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond. 1143 1144 INPUT : ms (AKA: MilliSeconds) 1145 OUTPUT: tuple(string TIMELAPSED, string SPOKENTIME) like format. 1146 OUTPUT Variables: TIMELAPSED, SPOKENTIME 1147 1148 Example Input: DurationFromNanoSeconds(ns) 1149 **"Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds"** 1150 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') 1151 DurationFromNanoSeconds(1234567890123456789012) 1152 """ 1153 μs, ns = divmod(ns, 1000) 1154 ms, μs = divmod(μs, 1000) 1155 s, ms = divmod(ms, 1000) 1156 m, s = divmod(s, 60) 1157 h, m = divmod(m, 60) 1158 d, h = divmod(h, 24) 1159 y, d = divmod(d, 365) 1160 c, y = divmod(y, 100) 1161 n, c = divmod(c, 10) 1162 TIMELAPSED = 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}::{μs:03.0f}::{ns:03.0f}" 1163 SPOKENTIME = 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, {μs: 3d} MicroSeconds, {ns: 3d} NanoSeconds" 1164 return TIMELAPSED, SPOKENTIME 1165 1166 @staticmethod 1167 def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime: 1168 """ 1169 Generate a random date between two given dates. 1170 1171 Parameters: 1172 start_date (datetime.datetime): The start date from which to generate a random date. 1173 end_date (datetime.datetime): The end date until which to generate a random date. 1174 1175 Returns: 1176 datetime.datetime: A random date between the start_date and end_date. 1177 """ 1178 time_between_dates = end_date - start_date 1179 days_between_dates = time_between_dates.days 1180 random_number_of_days = random.randrange(days_between_dates) 1181 return start_date + datetime.timedelta(days=random_number_of_days) 1182 1183 @staticmethod 1184 def generate_random_csv_file(path: str = "data.csv", count: int = 1000) -> None: 1185 """ 1186 Generate a random CSV file with specified parameters. 1187 1188 Parameters: 1189 path (str): The path where the CSV file will be saved. Default is "data.csv". 1190 count (int): The number of rows to generate in the CSV file. Default is 1000. 1191 1192 Returns: 1193 None. The function generates a CSV file at the specified path with the given count of rows. 1194 Each row contains a randomly generated account, description, value, and date. 1195 The value is randomly generated between 1000 and 100000, and the date is randomly generated between 1950-01-01 and 2023-12-31. 1196 If the row number is not divisible by 13, the value is multiplied by -1. 1197 """ 1198 with open(path, "w", newline="") as csvfile: 1199 writer = csv.writer(csvfile) 1200 for i in range(count): 1201 account = f"acc-{random.randint(1, 1000)}" 1202 desc = f"Some text {random.randint(1, 1000)}" 1203 value = random.randint(1000, 100000) 1204 date = ZakatTracker.generate_random_date(datetime.datetime(1950, 1, 1), datetime.datetime(2023, 12, 31)).strftime("%Y-%m-%d %H:%M:%S") 1205 if not i % 13 == 0: 1206 value *= -1 1207 writer.writerow([account, desc, value, date]) 1208 1209 def _test_core(self, restore = False, debug = False): 1210 1211 random.seed(1234567890) 1212 1213 # sanity check - random forward time 1214 1215 xlist = [] 1216 limit = 1000 1217 for _ in range(limit): 1218 y = ZakatTracker.time() 1219 z = '-' 1220 if not y in xlist: 1221 xlist.append(y) 1222 else: 1223 z = 'x' 1224 if debug: 1225 print(z, y) 1226 xx = len(xlist) 1227 if debug: 1228 print('count', xx, ' - unique: ', (xx/limit)*100, '%') 1229 assert limit == xx 1230 1231 # sanity check - convert date since 1000AD 1232 1233 for year in range(1000, 9000): 1234 ns = ZakatTracker.time(datetime.datetime.strptime(f"{year}-12-30 18:30:45","%Y-%m-%d %H:%M:%S")) 1235 date = ZakatTracker.time_to_datetime(ns) 1236 if debug: 1237 print(date) 1238 assert date.year == year 1239 assert date.month == 12 1240 assert date.day == 30 1241 assert date.hour == 18 1242 assert date.minute == 30 1243 assert date.second in [44, 45] 1244 assert self.nolock() 1245 1246 assert self._history() is True 1247 1248 table = { 1249 1: [ 1250 (0, 10, 10, 10, 10, 1, 1), 1251 (0, 20, 30, 30, 30, 2, 2), 1252 (0, 30, 60, 60, 60, 3, 3), 1253 (1, 15, 45, 45, 45, 3, 4), 1254 (1, 50, -5, -5, -5, 4, 5), 1255 (1, 100, -105, -105, -105, 5, 6), 1256 ], 1257 'wallet': [ 1258 (1, 90, -90, -90, -90, 1, 1), 1259 (0, 100, 10, 10, 10, 2, 2), 1260 (1, 190, -180, -180, -180, 3, 3), 1261 (0, 1000, 820, 820, 820, 4, 4), 1262 ], 1263 } 1264 for x in table: 1265 for y in table[x]: 1266 self.lock() 1267 ref = 0 1268 if y[0] == 0: 1269 ref = self.track(y[1], 'test-add', x, True, ZakatTracker.time(), debug) 1270 else: 1271 (ref, z) = self.sub(y[1], 'test-sub', x, ZakatTracker.time()) 1272 if debug: 1273 print('_sub', z, ZakatTracker.time()) 1274 assert ref != 0 1275 assert len(self._vault['account'][x]['log'][ref]['file']) == 0 1276 for i in range(3): 1277 file_ref = self.add_file(x, ref, 'file_' + str(i)) 1278 sleep(0.0000001) 1279 assert file_ref != 0 1280 if debug: 1281 print('ref', ref, 'file', file_ref) 1282 assert len(self._vault['account'][x]['log'][ref]['file']) == i+1 1283 file_ref = self.add_file(x, ref, 'file_' + str(3)) 1284 assert self.remove_file(x, ref, file_ref) 1285 assert self.balance(x) == y[2] 1286 z = self.balance(x, False) 1287 if debug: 1288 print("debug-1", z, y[3]) 1289 assert z == y[3] 1290 l = self._vault['account'][x]['log'] 1291 z = 0 1292 for i in l: 1293 z += l[i]['value'] 1294 if debug: 1295 print("debug-2", z, type(z)) 1296 print("debug-2", y[4], type(y[4])) 1297 assert z == y[4] 1298 if debug: 1299 print('debug-2 - PASSED') 1300 assert self.box_size(x) == y[5] 1301 assert self.log_size(x) == y[6] 1302 assert not self.nolock() 1303 self.free(self.lock()) 1304 assert self.nolock() 1305 assert self.boxes(x) != {} 1306 assert self.logs(x) != {} 1307 1308 assert self.zakatable(x) 1309 assert self.zakatable(x, False) is False 1310 assert self.zakatable(x) is False 1311 assert self.zakatable(x, True) 1312 assert self.zakatable(x) 1313 1314 if restore is True: 1315 count = len(self._vault['history']) 1316 if debug: 1317 print('history-count', count) 1318 assert count == 10 1319 # try mode 1320 for _ in range(count): 1321 assert self.recall(True, debug) 1322 count = len(self._vault['history']) 1323 if debug: 1324 print('history-count', count) 1325 assert count == 10 1326 _accounts = list(table.keys()) 1327 accounts_limit = len(_accounts) + 1 1328 for i in range(-1, -accounts_limit, -1): 1329 account = _accounts[i] 1330 if debug: 1331 print(account, len(table[account])) 1332 transaction_limit = len(table[account]) + 1 1333 for j in range(-1, -transaction_limit, -1): 1334 row = table[account][j] 1335 if debug: 1336 print(row, self.balance(account), self.balance(account, False)) 1337 assert self.balance(account) == self.balance(account, False) 1338 assert self.balance(account) == row[2] 1339 assert self.recall(False, debug) 1340 assert self.recall(False, debug) is False 1341 count = len(self._vault['history']) 1342 if debug: 1343 print('history-count', count) 1344 assert count == 0 1345 self.reset() 1346 1347 def test(self, debug: bool = False): 1348 1349 try: 1350 1351 assert self._history() 1352 1353 # Not allowed for duplicate transactions in the same account and time 1354 1355 created = ZakatTracker.time() 1356 self.track(100, 'test-1', 'same', True, created) 1357 failed = False 1358 try: 1359 self.track(50, 'test-1', 'same', True, created) 1360 except: 1361 failed = True 1362 assert failed is True 1363 1364 self.reset() 1365 1366 # Always preserve box age during transfer 1367 1368 created = ZakatTracker.time() 1369 series = [ 1370 (30, 4), 1371 (60, 3), 1372 (90, 2), 1373 ] 1374 case = { 1375 30: { 1376 'series': series, 1377 'rest' : 150, 1378 }, 1379 60: { 1380 'series': series, 1381 'rest' : 120, 1382 }, 1383 90: { 1384 'series': series, 1385 'rest' : 90, 1386 }, 1387 180: { 1388 'series': series, 1389 'rest' : 0, 1390 }, 1391 270: { 1392 'series': series, 1393 'rest' : -90, 1394 }, 1395 360: { 1396 'series': series, 1397 'rest' : -180, 1398 }, 1399 } 1400 1401 selected_time = ZakatTracker.time() - ZakatTracker.TimeCycle() 1402 1403 for total in case: 1404 for x in case[total]['series']: 1405 self.track(x[0], f"test-{x} ages", 'ages', True, selected_time * x[1]) 1406 1407 refs = self.transfer(total, 'ages', 'future', 'Zakat Movement') 1408 1409 if debug: 1410 print('refs', refs) 1411 1412 ages_cache_balance = self.balance('ages') 1413 ages_fresh_balance = self.balance('ages', False) 1414 rest = case[total]['rest'] 1415 if debug: 1416 print('source', ages_cache_balance, ages_fresh_balance, rest) 1417 assert ages_cache_balance == rest 1418 assert ages_fresh_balance == rest 1419 1420 future_cache_balance = self.balance('future') 1421 future_fresh_balance = self.balance('future', False) 1422 if debug: 1423 print('target', future_cache_balance, future_fresh_balance, total) 1424 print('refs', refs) 1425 assert future_cache_balance == total 1426 assert future_fresh_balance == total 1427 1428 for ref in self._vault['account']['ages']['box']: 1429 ages_capital = self._vault['account']['ages']['box'][ref]['capital'] 1430 ages_rest = self._vault['account']['ages']['box'][ref]['rest'] 1431 future_capital = 0 1432 if ref in self._vault['account']['future']['box']: 1433 future_capital = self._vault['account']['future']['box'][ref]['capital'] 1434 future_rest = 0 1435 if ref in self._vault['account']['future']['box']: 1436 future_rest = self._vault['account']['future']['box'][ref]['rest'] 1437 if ages_capital != 0 and future_capital != 0 and future_rest != 0: 1438 if debug: 1439 print('================================================================') 1440 print('ages', ages_capital, ages_rest) 1441 print('future', future_capital, future_rest) 1442 if ages_rest == 0: 1443 assert ages_capital == future_capital 1444 elif ages_rest < 0: 1445 assert -ages_capital == future_capital 1446 elif ages_rest > 0: 1447 assert ages_capital == ages_rest + future_capital 1448 self.reset() 1449 assert len(self._vault['history']) == 0 1450 1451 assert self._history() 1452 assert self._history(False) is False 1453 assert self._history() is False 1454 assert self._history(True) 1455 assert self._history() 1456 1457 self._test_core(True, debug) 1458 self._test_core(False, debug) 1459 1460 transaction = [ 1461 ( 1462 20, 'wallet', 1, 800, 800, 800, 4, 5, 1463 -85, -85, -85, 6, 7, 1464 ), 1465 ( 1466 750, 'wallet', 'safe', 50, 50, 50, 4, 6, 1467 750, 750, 750, 1, 1, 1468 ), 1469 ( 1470 600, 'safe', 'bank', 150, 150, 150, 1, 2, 1471 600, 600, 600, 1, 1, 1472 ), 1473 ] 1474 for z in transaction: 1475 self.lock() 1476 x = z[1] 1477 y = z[2] 1478 self.transfer(z[0], x, y, 'test-transfer') 1479 assert self.balance(x) == z[3] 1480 xx = self.accounts()[x] 1481 assert xx == z[3] 1482 assert self.balance(x, False) == z[4] 1483 assert xx == z[4] 1484 1485 l = self._vault['account'][x]['log'] 1486 s = 0 1487 for i in l: 1488 s += l[i]['value'] 1489 if debug: 1490 print('s', s, 'z[5]', z[5]) 1491 assert s == z[5] 1492 1493 assert self.box_size(x) == z[6] 1494 assert self.log_size(x) == z[7] 1495 1496 yy = self.accounts()[y] 1497 assert self.balance(y) == z[8] 1498 assert yy == z[8] 1499 assert self.balance(y, False) == z[9] 1500 assert yy == z[9] 1501 1502 l = self._vault['account'][y]['log'] 1503 s = 0 1504 for i in l: 1505 s += l[i]['value'] 1506 assert s == z[10] 1507 1508 assert self.box_size(y) == z[11] 1509 assert self.log_size(y) == z[12] 1510 1511 if debug: 1512 pp().pprint(self.check(2.17)) 1513 1514 assert not self.nolock() 1515 history_count = len(self._vault['history']) 1516 if debug: 1517 print('history-count', history_count) 1518 assert history_count == 11 1519 assert not self.free(ZakatTracker.time()) 1520 assert self.free(self.lock()) 1521 assert self.nolock() 1522 assert len(self._vault['history']) == 11 1523 1524 # storage 1525 1526 _path = self.path('test.pickle') 1527 if os.path.exists(_path): 1528 os.remove(_path) 1529 self.save() 1530 assert os.path.getsize(_path) > 0 1531 self.reset() 1532 assert self.recall(False, debug) is False 1533 self.load() 1534 assert self._vault['account'] is not None 1535 1536 # recall 1537 1538 assert self.nolock() 1539 assert len(self._vault['history']) == 11 1540 assert self.recall(False, debug) is True 1541 assert len(self._vault['history']) == 10 1542 assert self.recall(False, debug) is True 1543 assert len(self._vault['history']) == 9 1544 1545 # csv 1546 1547 _path = "test.csv" 1548 count = 1000 1549 if os.path.exists(_path): 1550 os.remove(_path) 1551 self.generate_random_csv_file(_path, count) 1552 assert os.path.getsize(_path) > 0 1553 tmp = "tmp" 1554 if os.path.exists(tmp): 1555 os.remove(tmp) 1556 (created, found, bad) = self.import_csv(_path) 1557 bad_count = len(bad) 1558 if debug: 1559 print(f"csv-imported: ({created}, {found}, {bad_count})") 1560 tmp_size = os.path.getsize(tmp) 1561 assert tmp_size > 0 1562 assert created + found + bad_count == count 1563 assert created == count 1564 assert bad_count == 0 1565 (created_2, found_2, bad_2) = self.import_csv(_path) 1566 bad_2_count = len(bad_2) 1567 if debug: 1568 print(f"csv-imported: ({created_2}, {found_2}, {bad_2_count})") 1569 print(bad) 1570 assert tmp_size == os.path.getsize(tmp) 1571 assert created_2 + found_2 + bad_2_count == count 1572 assert created == found_2 1573 assert bad_count == bad_2_count 1574 assert found_2 == count 1575 assert bad_2_count == 0 1576 assert created_2 == 0 1577 1578 # payment parts 1579 1580 positive_parts = self.build_payment_parts(100, positive_only=True) 1581 assert self.check_payment_parts(positive_parts) != 0 1582 assert self.check_payment_parts(positive_parts) != 0 1583 all_parts = self.build_payment_parts(300, positive_only= False) 1584 assert self.check_payment_parts(all_parts) != 0 1585 assert self.check_payment_parts(all_parts) != 0 1586 if debug: 1587 pp().pprint(positive_parts) 1588 pp().pprint(all_parts) 1589 # dynamic discount 1590 suite = [] 1591 count = 3 1592 for exceed in [False, True]: 1593 case = [] 1594 for parts in [positive_parts, all_parts]: 1595 part = parts.copy() 1596 demand = part['demand'] 1597 if debug: 1598 print(demand, part['total']) 1599 i = 0 1600 z = demand / count 1601 cp = { 1602 'account': {}, 1603 'demand': demand, 1604 'exceed': exceed, 1605 'total': part['total'], 1606 } 1607 for x, y in part['account'].items(): 1608 if exceed and z <= demand: 1609 i += 1 1610 y['part'] = z 1611 if debug: 1612 print(exceed, y) 1613 cp['account'][x] = y 1614 case.append(y) 1615 elif not exceed and y['balance'] >= z: 1616 i += 1 1617 y['part'] = z 1618 if debug: 1619 print(exceed, y) 1620 cp['account'][x] = y 1621 case.append(y) 1622 if i >= count: 1623 break 1624 if len(cp['account'][x]) > 0: 1625 suite.append(cp) 1626 if debug: 1627 print('suite', len(suite)) 1628 for case in suite: 1629 if debug: 1630 print(case) 1631 result = self.check_payment_parts(case) 1632 if debug: 1633 print('check_payment_parts', result) 1634 assert result == 0 1635 1636 report = self.check(2.17, None, debug) 1637 (valid, brief, plan) = report 1638 if debug: 1639 print('valid', valid) 1640 assert self.zakat(report, parts=suite, debug=debug) 1641 1642 assert self.export_json("1000-transactions-test.json") 1643 assert self.save("1000-transactions-test.pickle") 1644 1645 # check & zakat 1646 1647 cases = [ 1648 (1000, 'safe', ZakatTracker.time()-ZakatTracker.TimeCycle(), [ 1649 {'safe': {0: {'below_nisab': 25}}}, 1650 ], False), 1651 (2000, 'safe', ZakatTracker.time()-ZakatTracker.TimeCycle(), [ 1652 {'safe': {0: {'count': 1, 'total': 50}}}, 1653 ], True), 1654 (10000, 'cave', ZakatTracker.time()-(ZakatTracker.TimeCycle()*3), [ 1655 {'cave': {0: {'count': 3, 'total': 731.40625}}}, 1656 ], True), 1657 ] 1658 for case in cases: 1659 if debug: 1660 print("############# check #############") 1661 self.reset() 1662 self.track(case[0], 'test-check', case[1], True, case[2]) 1663 1664 assert self.nolock() 1665 assert len(self._vault['history']) == 1 1666 assert self.lock() 1667 assert not self.nolock() 1668 report = self.check(2.17, None, debug) 1669 (valid, brief, plan) = report 1670 assert valid == case[4] 1671 if debug: 1672 print(brief) 1673 assert case[0] == brief[0] 1674 assert case[0] == brief[1] 1675 1676 if debug: 1677 pp().pprint(plan) 1678 1679 for x in plan: 1680 assert case[1] == x 1681 if 'total' in case[3][0][x][0].keys(): 1682 assert case[3][0][x][0]['total'] == brief[2] 1683 assert plan[x][0]['total'] == case[3][0][x][0]['total'] 1684 assert plan[x][0]['count'] == case[3][0][x][0]['count'] 1685 else: 1686 assert plan[x][0]['below_nisab'] == case[3][0][x][0]['below_nisab'] 1687 if debug: 1688 pp().pprint(report) 1689 result = self.zakat(report, debug=debug) 1690 if debug: 1691 print('zakat-result', result, case[4]) 1692 assert result == case[4] 1693 report = self.check(2.17, None, debug) 1694 (valid, brief, plan) = report 1695 assert valid is False 1696 1697 assert len(self._vault['history']) == 2 1698 assert not self.nolock() 1699 assert self.recall(False, debug) is False 1700 self.free(self.lock()) 1701 assert self.nolock() 1702 assert len(self._vault['history']) == 2 1703 assert self.recall(False, debug) is True 1704 assert len(self._vault['history']) == 1 1705 1706 assert self.nolock() 1707 assert len(self._vault['history']) == 1 1708 1709 assert self.recall(False, debug) is True 1710 assert len(self._vault['history']) == 0 1711 1712 assert len(self._vault['account']) == 0 1713 assert len(self._vault['history']) == 0 1714 assert len(self._vault['report']) == 0 1715 assert self.nolock() 1716 except: 1717 pp().pprint(self._vault) 1718 raise
A class for tracking and calculating Zakat.
This class provides functionalities for recording transactions, calculating Zakat due, and managing account balances. It also offers features like importing transactions from CSV files, exporting data to JSON format, and saving/loading the tracker state.
The ZakatTracker
class is designed to handle both positive and negative transactions,
allowing for flexible tracking of financial activities related to Zakat. It also supports
the concept of a "nisab" (minimum threshold for Zakat) and a "haul" (complete one year for Transaction) can calculate Zakat due
based on the current silver price.
The class uses a pickle file as its database to persist the tracker state, ensuring data integrity across sessions. It also provides options for enabling or disabling history tracking, allowing users to choose their preferred level of detail.
In addition, the ZakatTracker
class includes various helper methods like
time
, time_to_datetime
, lock
, free
, recall
, export_json
,
and more. These methods provide additional functionalities and flexibility
for interacting with and managing the Zakat tracker.
Attributes: ZakatCut (function): A function to calculate the Zakat percentage. TimeCycle (function): A function to determine the time cycle for Zakat. Nisab (function): A function to calculate the Nisab based on the silver price. Version (str): The version of the ZakatTracker class.
Data Structure:
The ZakatTracker class utilizes a nested dictionary structure called "_vault" to store and manage data.
_vault (dict):
- account (dict):
- {account_number} (dict):
- balance (int): The current balance of the account.
- box (dict): A dictionary storing transaction details.
- {timestamp} (dict):
- capital (int): The initial amount of the transaction.
- count (int): The number of times Zakat has been calculated for this transaction.
- last (int): The timestamp of the last Zakat calculation.
- rest (int): The remaining amount after Zakat deductions and withdrawal.
- total (int): The total Zakat deducted from this transaction.
- count (int): The total number of transactions for the account.
- log (dict): A dictionary storing transaction logs.
- {timestamp} (dict):
- value (int): The transaction amount (positive or negative).
- desc (str): The description of the transaction.
- file (dict): A dictionary storing file references associated with the transaction.
- zakatable (bool): Indicates whether the account is subject to Zakat.
- history (dict):
- {timestamp} (list): A list of dictionaries storing the history of actions performed.
- {action_dict} (dict):
- action (Action): The type of action (CREATE, TRACK, LOG, SUB, ADD_FILE, REMOVE_FILE, 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.
160 def __init__(self, db_path: str = "zakat.pickle", history_mode: bool = True): 161 """ 162 Initialize ZakatTracker with database path and history mode. 163 164 Parameters: 165 db_path (str): The path to the database file. Default is "zakat.pickle". 166 history_mode (bool): The mode for tracking history. Default is True. 167 168 Returns: 169 None 170 """ 171 self.reset() 172 self._history(history_mode) 173 self.path(db_path) 174 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
176 def path(self, _path: str = None) -> str: 177 """ 178 Set or get the database path. 179 180 Parameters: 181 _path (str): The path to the database file. If not provided, it returns the current path. 182 183 Returns: 184 str: The current database path. 185 """ 186 if _path is not None: 187 self._vault_path = _path 188 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.
204 def reset(self) -> None: 205 """ 206 Reset the internal data structure to its initial state. 207 208 Parameters: 209 None 210 211 Returns: 212 None 213 """ 214 self._vault = {} 215 self._vault['account'] = {} 216 self._vault['history'] = {} 217 self._vault['lock'] = None 218 self._vault['report'] = {}
Reset the internal data structure to its initial state.
Parameters: None
Returns: None
220 @staticmethod 221 def time(now: datetime = None) -> int: 222 """ 223 Generates a timestamp based on the provided datetime object or the current datetime. 224 225 Parameters: 226 now (datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used. 227 228 Returns: 229 int: The timestamp in positive nanoseconds since the Unix epoch (January 1, 1970), before 1970 will return in negative until 1000AD. 230 """ 231 if now is None: 232 now = datetime.datetime.now() 233 ordinal_day = now.toordinal() 234 ns_in_day = (now - now.replace(hour=0, minute=0, second=0, microsecond=0)).total_seconds() * 10**9 235 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.
237 @staticmethod 238 def time_to_datetime(ordinal_ns: int) -> datetime: 239 """ 240 Converts an ordinal number (number of days since 1000-01-01) to a datetime object. 241 242 Parameters: 243 ordinal_ns (int): The ordinal number of days since 1000-01-01. 244 245 Returns: 246 datetime: The corresponding datetime object. 247 """ 248 ordinal_day = ordinal_ns // 86_400_000_000_000 + 719_163 249 ns_in_day = ordinal_ns % 86_400_000_000_000 250 d = datetime.datetime.fromordinal(ordinal_day) 251 t = datetime.timedelta(seconds=ns_in_day // 10**9) 252 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.
288 def nolock(self) -> bool: 289 """ 290 Check if the vault lock is currently not set. 291 292 :return: True if the vault lock is not set, False otherwise. 293 """ 294 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.
296 def lock(self) -> int: 297 """ 298 Acquires a lock on the ZakatTracker instance. 299 300 Returns: 301 int: The lock ID. This ID can be used to release the lock later. 302 """ 303 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.
305 def box(self) -> dict: 306 """ 307 Returns a copy of the internal vault dictionary. 308 309 This method is used to retrieve the current state of the ZakatTracker object. 310 It provides a snapshot of the internal data structure, allowing for further 311 processing or analysis. 312 313 :return: A copy of the internal vault dictionary. 314 """ 315 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.
317 def steps(self) -> dict: 318 """ 319 Returns a copy of the history of steps taken in the ZakatTracker. 320 321 The history is a dictionary where each key is a unique identifier for a step, 322 and the corresponding value is a dictionary containing information about the step. 323 324 :return: A copy of the history of steps taken in the ZakatTracker. 325 """ 326 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.
328 def free(self, _lock: int, auto_save: bool = True) -> bool: 329 """ 330 Releases the lock on the database. 331 332 Parameters: 333 _lock (int): The lock ID to be released. 334 auto_save (bool): Whether to automatically save the database after releasing the lock. 335 336 Returns: 337 bool: True if the lock is successfully released and (optionally) saved, False otherwise. 338 """ 339 if _lock == self._vault['lock']: 340 self._vault['lock'] = None 341 if auto_save: 342 return self.save(self.path()) 343 return True 344 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.
346 def account_exists(self, account) -> bool: 347 """ 348 Check if the given account exists in the vault. 349 350 Parameters: 351 account (str): The account number to check. 352 353 Returns: 354 bool: True if the account exists, False otherwise. 355 """ 356 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.
358 def box_size(self, account) -> int: 359 """ 360 Calculate the size of the box for a specific account. 361 362 Parameters: 363 account (str): The account number for which the box size needs to be calculated. 364 365 Returns: 366 int: The size of the box for the given account. If the account does not exist, -1 is returned. 367 """ 368 if self.account_exists(account): 369 return len(self._vault['account'][account]['box']) 370 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.
372 def log_size(self, account) -> int: 373 """ 374 Get the size of the log for a specific account. 375 376 Parameters: 377 account (str): The account number for which the log size needs to be calculated. 378 379 Returns: 380 int: The size of the log for the given account. If the account does not exist, -1 is returned. 381 """ 382 if self.account_exists(account): 383 return len(self._vault['account'][account]['log']) 384 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.
386 def recall(self, dry = True, debug = False) -> bool: 387 """ 388 Revert the last operation. 389 390 Parameters: 391 dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True. 392 debug (bool): If True, the function will print debug information. Default is False. 393 394 Returns: 395 bool: True if the operation was successful, False otherwise. 396 """ 397 if not self.nolock() or len(self._vault['history']) == 0: 398 return False 399 if len(self._vault['history']) <= 0: 400 return 401 ref = sorted(self._vault['history'].keys())[-1] 402 if debug: 403 print('recall', ref) 404 memory = self._vault['history'][ref] 405 if debug: 406 print(type(memory), 'memory', memory) 407 408 limit = len(memory) + 1 409 sub_positive_log_negative = 0 410 for i in range(-1, -limit, -1): 411 x = memory[i] 412 if debug: 413 print(type(x), x) 414 match x['action']: 415 case Action.CREATE: 416 if x['account'] is not None: 417 if self.account_exists(x['account']): 418 if debug: 419 print('account', self._vault['account'][x['account']]) 420 assert len(self._vault['account'][x['account']]['box']) == 0 421 assert self._vault['account'][x['account']]['balance'] == 0 422 assert self._vault['account'][x['account']]['count'] == 0 423 if dry: 424 continue 425 del self._vault['account'][x['account']] 426 427 case Action.TRACK: 428 if x['account'] is not None: 429 if self.account_exists(x['account']): 430 if dry: 431 continue 432 self._vault['account'][x['account']]['balance'] -= x['value'] 433 self._vault['account'][x['account']]['count'] -= 1 434 del self._vault['account'][x['account']]['box'][x['ref']] 435 436 case Action.LOG: 437 if x['account'] is not None: 438 if self.account_exists(x['account']): 439 if x['ref'] in self._vault['account'][x['account']]['log']: 440 if dry: 441 continue 442 if sub_positive_log_negative == -x['value']: 443 self._vault['account'][x['account']]['count'] -= 1 444 sub_positive_log_negative = 0 445 del self._vault['account'][x['account']]['log'][x['ref']] 446 447 case Action.SUB: 448 if x['account'] is not None: 449 if self.account_exists(x['account']): 450 if x['ref'] in self._vault['account'][x['account']]['box']: 451 if dry: 452 continue 453 self._vault['account'][x['account']]['box'][x['ref']]['rest'] += x['value'] 454 self._vault['account'][x['account']]['balance'] += x['value'] 455 sub_positive_log_negative = x['value'] 456 457 case Action.ADD_FILE: 458 if x['account'] is not None: 459 if self.account_exists(x['account']): 460 if x['ref'] in self._vault['account'][x['account']]['log']: 461 if x['file'] in self._vault['account'][x['account']]['log'][x['ref']]['file']: 462 if dry: 463 continue 464 del self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']] 465 466 case Action.REMOVE_FILE: 467 if x['account'] is not None: 468 if self.account_exists(x['account']): 469 if x['ref'] in self._vault['account'][x['account']]['log']: 470 if dry: 471 continue 472 self._vault['account'][x['account']]['log'][x['ref']]['file'][x['file']] = x['value'] 473 474 case Action.REPORT: 475 if x['ref'] in self._vault['report']: 476 if dry: 477 continue 478 del self._vault['report'][x['ref']] 479 480 case Action.ZAKAT: 481 if x['account'] is not None: 482 if self.account_exists(x['account']): 483 if x['ref'] in self._vault['account'][x['account']]['box']: 484 if x['key'] in self._vault['account'][x['account']]['box'][x['ref']]: 485 if dry: 486 continue 487 match x['math']: 488 case MathOperation.ADDITION: 489 self._vault['account'][x['account']]['box'][x['ref']][x['key']] -= x['value'] 490 case MathOperation.EQUAL: 491 self._vault['account'][x['account']]['box'][x['ref']][x['key']] = x['value'] 492 case MathOperation.SUBTRACTION: 493 self._vault['account'][x['account']]['box'][x['ref']][x['key']] += x['value'] 494 495 if not dry: 496 del self._vault['history'][ref] 497 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.
499 def track(self, value: int = 0, desc: str = '', account: str = 1, logging: bool = True, created: int = None, debug: bool = False) -> int: 500 """ 501 This function tracks a transaction for a specific account. 502 503 Parameters: 504 value (int): The value of the transaction. Default is 0. 505 desc (str): The description of the transaction. Default is an empty string. 506 account (str): The account for which the transaction is being tracked. Default is '1'. 507 logging (bool): Whether to log the transaction. Default is True. 508 created (int): The timestamp of the transaction. If not provided, it will be generated. Default is None. 509 debug (bool): Whether to print debug information. Default is False. 510 511 Returns: 512 int: The timestamp of the transaction. 513 514 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. 515 516 Raises: 517 ValueError: If the transction happend again in the same time. 518 """ 519 if created is None: 520 created = ZakatTracker.time() 521 _nolock = self.nolock(); self.lock() 522 if not self.account_exists(account): 523 if debug: 524 print(f"account {account} created") 525 self._vault['account'][account] = { 526 'balance': 0, 527 'box': {}, 528 'count': 0, 529 'log': {}, 530 'zakatable': True, 531 } 532 self._step(Action.CREATE, account) 533 if value == 0: 534 if _nolock: self.free(self.lock()) 535 return 0 536 if logging: 537 self._log(value, desc, account, created) 538 assert created not in self._vault['account'][account]['box'] 539 self._vault['account'][account]['box'][created] = { 540 'capital': value, 541 'count': 0, 542 'last': 0, 543 'rest': value, 544 'total': 0, 545 } 546 self._step(Action.TRACK, account, ref=created, value=value) 547 if _nolock: self.free(self.lock()) 548 return created
This function tracks a transaction for a specific account.
Parameters: value (int): 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: If the transction happend again in the same time.
582 def accounts(self) -> dict: 583 """ 584 Returns a dictionary containing account numbers as keys and their respective balances as values. 585 586 Parameters: 587 None 588 589 Returns: 590 dict: A dictionary where keys are account numbers and values are their respective balances. 591 """ 592 result = {} 593 for i in self._vault['account']: 594 result[i] = self._vault['account'][i]['balance'] 595 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.
597 def boxes(self, account) -> dict: 598 """ 599 Retrieve the boxes (transactions) associated with a specific account. 600 601 Parameters: 602 account (str): The account number for which to retrieve the boxes. 603 604 Returns: 605 dict: A dictionary containing the boxes associated with the given account. 606 If the account does not exist, an empty dictionary is returned. 607 """ 608 if self.account_exists(account): 609 return self._vault['account'][account]['box'] 610 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.
612 def logs(self, account) -> dict: 613 """ 614 Retrieve the logs (transactions) associated with a specific account. 615 616 Parameters: 617 account (str): The account number for which to retrieve the logs. 618 619 Returns: 620 dict: A dictionary containing the logs associated with the given account. 621 If the account does not exist, an empty dictionary is returned. 622 """ 623 if self.account_exists(account): 624 return self._vault['account'][account]['log'] 625 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.
627 def add_file(self, account: str, ref: int, path: str) -> int: 628 """ 629 Adds a file reference to a specific transaction log entry in the vault. 630 631 Parameters: 632 account (str): The account number associated with the transaction log. 633 ref (int): The reference to the transaction log entry. 634 path (str): The path of the file to be added. 635 636 Returns: 637 int: The reference of the added file. If the account or transaction log entry does not exist, returns 0. 638 """ 639 if self.account_exists(account): 640 if ref in self._vault['account'][account]['log']: 641 file_ref = ZakatTracker.time() 642 self._vault['account'][account]['log'][ref]['file'][file_ref] = path 643 _nolock = self.nolock(); self.lock() 644 self._step(Action.ADD_FILE, account, ref=ref, file=file_ref) 645 if _nolock: self.free(self.lock()) 646 return file_ref 647 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.
649 def remove_file(self, account: str, ref: int, file_ref: int) -> bool: 650 """ 651 Removes a file reference from a specific transaction log entry in the vault. 652 653 Parameters: 654 account (str): The account number associated with the transaction log. 655 ref (int): The reference to the transaction log entry. 656 file_ref (int): The reference of the file to be removed. 657 658 Returns: 659 bool: True if the file reference is successfully removed, False otherwise. 660 """ 661 if self.account_exists(account): 662 if ref in self._vault['account'][account]['log']: 663 if file_ref in self._vault['account'][account]['log'][ref]['file']: 664 x = self._vault['account'][account]['log'][ref]['file'][file_ref] 665 del self._vault['account'][account]['log'][ref]['file'][file_ref] 666 _nolock = self.nolock(); self.lock() 667 self._step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x) 668 if _nolock: self.free(self.lock()) 669 return True 670 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.
672 def balance(self, account: str = 1, cached: bool = True) -> int: 673 """ 674 Calculate and return the balance of a specific account. 675 676 Parameters: 677 account (str): The account number. Default is '1'. 678 cached (bool): If True, use the cached balance. If False, calculate the balance from the box. Default is True. 679 680 Returns: 681 int: The balance of the account. 682 683 Note: 684 If cached is True, the function returns the cached balance. 685 If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items. 686 """ 687 if cached: 688 return self._vault['account'][account]['balance'] 689 x = 0 690 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.
692 def zakatable(self, account, status: bool = None) -> bool: 693 """ 694 Check or set the zakatable status of a specific account. 695 696 Parameters: 697 account (str): The account number. 698 status (bool, optional): The new zakatable status. If not provided, the function will return the current status. 699 700 Returns: 701 bool: The current or updated zakatable status of the account. 702 703 Raises: 704 None 705 706 Example: 707 >>> tracker = ZakatTracker() 708 >>> tracker.zakatable('account1', True) # Set the zakatable status of 'account1' to True 709 True 710 >>> tracker.zakatable('account1') # Get the zakatable status of 'account1' 711 True 712 """ 713 if self.account_exists(account): 714 if status is None: 715 return self._vault['account'][account]['zakatable'] 716 self._vault['account'][account]['zakatable'] = status 717 return status 718 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()
>>> tracker.zakatable('account1', True) # Set the zakatable status of 'account1' to True
True
>>> tracker.zakatable('account1') # Get the zakatable status of 'account1'
True
720 def sub(self, x: int, desc: str = '', account: str = 1, created: int = None, debug: bool = False) -> int: 721 """ 722 Subtracts a specified amount from the account's balance. 723 724 Parameters: 725 x (int): The amount to subtract. 726 desc (str): The description of the transaction. 727 account (str): The account from which to subtract. 728 created (int): The timestamp of the transaction. 729 debug (bool): A flag to enable debug mode. 730 731 Returns: 732 int: The timestamp of the transaction. 733 734 If the amount to subtract is greater than the account's balance, 735 the remaining amount will be transferred to a new transaction with a negative value. 736 737 Raises: 738 ValueError: If the transction happend again in the same time. 739 """ 740 if x < 0: 741 return 742 if x == 0: 743 return self.track(x, '', account) 744 if created is None: 745 created = ZakatTracker.time() 746 _nolock = self.nolock(); self.lock() 747 self.track(0, '', account) 748 self._log(-x, desc, account, created) 749 ids = sorted(self._vault['account'][account]['box'].keys()) 750 limit = len(ids) + 1 751 target = x 752 if debug: 753 print('ids', ids) 754 ages = [] 755 for i in range(-1, -limit, -1): 756 if target == 0: 757 break 758 j = ids[i] 759 if debug: 760 print('i', i, 'j', j) 761 if self._vault['account'][account]['box'][j]['rest'] >= target: 762 self._vault['account'][account]['box'][j]['rest'] -= target 763 self._step(Action.SUB, account, ref=j, value=target) 764 ages.append((j, target)) 765 target = 0 766 break 767 elif self._vault['account'][account]['box'][j]['rest'] < target and self._vault['account'][account]['box'][j]['rest'] > 0: 768 chunk = self._vault['account'][account]['box'][j]['rest'] 769 target -= chunk 770 self._step(Action.SUB, account, ref=j, value=chunk) 771 ages.append((j, chunk)) 772 self._vault['account'][account]['box'][j]['rest'] = 0 773 if target > 0: 774 self.track(-target, desc, account, False, created) 775 ages.append((created, target)) 776 if _nolock: self.free(self.lock()) 777 return (created, ages)
Subtracts a specified amount from the account's balance.
Parameters: x (int): The amount to subtract. desc (str): The description of the transaction. account (str): The account from which to subtract. created (int): The timestamp of the transaction. debug (bool): A flag to enable debug mode.
Returns: int: The timestamp of the 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: If the transction happend again in the same time.
779 def transfer(self, value: int, from_account: str, to_account: str, desc: str = '', created: int = None) -> list[int]: 780 """ 781 Transfers a specified value from one account to another. 782 783 Parameters: 784 value (int): The amount to be transferred. 785 from_account (str): The account from which the value will be transferred. 786 to_account (str): The account to which the value will be transferred. 787 desc (str, optional): A description for the transaction. Defaults to an empty string. 788 created (int, optional): The timestamp of the transaction. If not provided, the current timestamp will be used. 789 790 Returns: 791 list[int]: A list of timestamps corresponding to the transactions made during the transfer. 792 793 Raises: 794 ValueError: If the transction happend again in the same time. 795 """ 796 if created is None: 797 created = ZakatTracker.time() 798 (_, ages) = self.sub(value, desc, from_account, created) 799 times = [] 800 for age in ages: 801 y = self.track(age[1], desc, to_account, logging=True, created=age[0]) 802 times.append(y) 803 return times
Transfers a specified value from one account to another.
Parameters: value (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.
Returns: list[int]: A list of timestamps corresponding to the transactions made during the transfer.
Raises: ValueError: If the transction happend again in the same time.
805 def check(self, silver_gram_price: float, nisab: float = None, debug: bool = False, now: int = None, cycle: float = None) -> tuple: 806 """ 807 Check the eligibility for Zakat based on the given parameters. 808 809 Parameters: 810 silver_gram_price (float): The price of a gram of silver. 811 nisab (float): The minimum amount of wealth required for Zakat. If not provided, it will be calculated based on the silver_gram_price. 812 debug (bool): Flag to enable debug mode. 813 now (int): The current timestamp. If not provided, it will be calculated using ZakatTracker.time(). 814 cycle (float): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle(). 815 816 Returns: 817 tuple: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics, and a dictionary containing the Zakat plan. 818 """ 819 if now is None: 820 now = ZakatTracker.time() 821 if cycle is None: 822 cycle = ZakatTracker.TimeCycle() 823 if nisab is None: 824 nisab = ZakatTracker.Nisab(silver_gram_price) 825 plan = {} 826 below_nisab = 0 827 brief = [0, 0, 0] 828 valid = False 829 for x in self._vault['account']: 830 if not self._vault['account'][x]['zakatable']: 831 continue 832 _box = self._vault['account'][x]['box'] 833 limit = len(_box) + 1 834 ids = sorted(self._vault['account'][x]['box'].keys()) 835 for i in range(-1, -limit, -1): 836 j = ids[i] 837 if _box[j]['rest'] <= 0: 838 continue 839 brief[0] += _box[j]['rest'] 840 index = limit + i - 1 841 epoch = (now - j) / cycle 842 if debug: 843 print(f"Epoch: {epoch}", _box[j]) 844 if _box[j]['last'] > 0: 845 epoch = (now - _box[j]['last']) / cycle 846 if debug: 847 print(f"Epoch: {epoch}") 848 epoch = floor(epoch) 849 if debug: 850 print(f"Epoch: {epoch}", type(epoch), epoch == 0, 1-epoch, epoch) 851 if epoch == 0: 852 continue 853 if debug: 854 print("Epoch - PASSED") 855 brief[1] += _box[j]['rest'] 856 if _box[j]['rest'] >= nisab: 857 total = 0 858 for _ in range(epoch): 859 total += ZakatTracker.ZakatCut(_box[j]['rest'] - total) 860 if total > 0: 861 if x not in plan: 862 plan[x] = {} 863 valid = True 864 brief[2] += total 865 plan[x][index] = {'total': total, 'count': epoch} 866 else: 867 chunk = ZakatTracker.ZakatCut(_box[j]['rest']) 868 if chunk > 0: 869 if x not in plan: 870 plan[x] = {} 871 if j not in plan[x].keys(): 872 plan[x][index] = {} 873 below_nisab += _box[j]['rest'] 874 brief[2] += chunk 875 plan[x][index]['below_nisab'] = chunk 876 valid = valid or below_nisab >= nisab 877 if debug: 878 print(f"below_nisab({below_nisab}) >= nisab({nisab})") 879 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.
881 def build_payment_parts(self, demand: float, positive_only: bool = True) -> dict: 882 """ 883 Build payment parts for the zakat distribution. 884 885 Parameters: 886 demand (float): The total demand for payment. 887 positive_only (bool): If True, only consider accounts with positive balance. Default is True. 888 889 Returns: 890 dict: A dictionary containing the payment parts for each account. The dictionary has the following structure: 891 { 892 'account': { 893 'account_id': {'balance': float, 'part': float}, 894 ... 895 }, 896 'exceed': bool, 897 'demand': float, 898 'total': float, 899 } 900 """ 901 total = 0 902 parts = { 903 'account': {}, 904 'exceed': False, 905 'demand': demand, 906 } 907 for x, y in self.accounts().items(): 908 if positive_only and y <= 0: 909 continue 910 total += y 911 parts['account'][x] = {'balance': y, 'part': 0} 912 parts['total'] = total 913 return parts
Build payment parts for the zakat distribution.
Parameters: demand (float): The total demand for payment. 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, 'part': float}, ... }, 'exceed': bool, 'demand': float, 'total': float, }
915 def check_payment_parts(self, parts: dict) -> int: 916 """ 917 Checks the validity of payment parts. 918 919 Parameters: 920 parts (dict): A dictionary containing payment parts information. 921 922 Returns: 923 int: Returns 0 if the payment parts are valid, otherwise returns the error code. 924 925 Error Codes: 926 1: 'demand', 'account', 'total', or 'exceed' key is missing in parts. 927 2: 'balance' or 'part' key is missing in parts['account'][x]. 928 3: 'part' value in parts['account'][x] is less than or equal to 0. 929 4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0. 930 5: 'part' value in parts['account'][x] is less than 0. 931 6: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value. 932 7: The sum of 'part' values in parts['account'] does not match with 'demand' value. 933 """ 934 for i in ['demand', 'account', 'total', 'exceed']: 935 if not i in parts: 936 return 1 937 exceed = parts['exceed'] 938 for x in parts['account']: 939 for j in ['balance', 'part']: 940 if not j in parts['account'][x]: 941 return 2 942 if parts['account'][x]['part'] <= 0: 943 return 3 944 if not exceed and parts['account'][x]['balance'] <= 0: 945 return 4 946 demand = parts['demand'] 947 z = 0 948 for _, y in parts['account'].items(): 949 if y['part'] < 0: 950 return 5 951 if not exceed and y['part'] > y['balance']: 952 return 6 953 z += y['part'] 954 if z != demand: 955 return 7 956 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' 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.
958 def zakat(self, report: tuple, parts: dict = None, debug: bool = False) -> bool: 959 """ 960 Perform Zakat calculation based on the given report and optional parts. 961 962 Parameters: 963 report (tuple): A tuple containing the validity of the report, the report data, and the zakat plan. 964 parts (dict): A dictionary containing the payment parts for the zakat. 965 debug (bool): A flag indicating whether to print debug information. 966 967 Returns: 968 bool: True if the zakat calculation is successful, False otherwise. 969 """ 970 (valid, _, plan) = report 971 if not valid: 972 return valid 973 parts_exist = parts is not None 974 if parts_exist: 975 for part in parts: 976 if self.check_payment_parts(part) != 0: 977 return False 978 if debug: 979 print('######### zakat #######') 980 print('parts_exist', parts_exist) 981 _nolock = self.nolock(); self.lock() 982 report_time = ZakatTracker.time() 983 self._vault['report'][report_time] = report 984 self._step(Action.REPORT, ref=report_time) 985 created = ZakatTracker.time() 986 for x in plan: 987 if debug: 988 print(plan[x]) 989 print('-------------') 990 print(self._vault['account'][x]['box']) 991 ids = sorted(self._vault['account'][x]['box'].keys()) 992 if debug: 993 print('plan[x]', plan[x]) 994 for i in plan[x].keys(): 995 j = ids[i] 996 if debug: 997 print('i', i, 'j', j) 998 self._step(Action.ZAKAT, x, j, value=self._vault['account'][x]['box'][j]['last'], key='last', math_operation=MathOperation.EQUAL) 999 self._vault['account'][x]['box'][j]['last'] = created 1000 self._vault['account'][x]['box'][j]['total'] += plan[x][i]['total'] 1001 self._step(Action.ZAKAT, x, j, value=plan[x][i]['total'], key='total', math_operation=MathOperation.ADDITION) 1002 self._vault['account'][x]['box'][j]['count'] += plan[x][i]['count'] 1003 self._step(Action.ZAKAT, x, j, value=plan[x][i]['count'], key='count', math_operation=MathOperation.ADDITION) 1004 if not parts_exist: 1005 self._vault['account'][x]['box'][j]['rest'] -= plan[x][i]['total'] 1006 self._step(Action.ZAKAT, x, j, value=plan[x][i]['total'], key='rest', math_operation=MathOperation.SUBTRACTION) 1007 if parts_exist: 1008 for transaction in parts: 1009 for account, part in transaction['account'].items(): 1010 if debug: 1011 print('zakat-part', account, part['part']) 1012 self.sub(part['part'], 'zakat-part', account, debug=debug) 1013 if _nolock: self.free(self.lock()) 1014 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.
1016 def export_json(self, path: str = "data.json") -> bool: 1017 """ 1018 Exports the current state of the ZakatTracker object to a JSON file. 1019 1020 Parameters: 1021 path (str): The path where the JSON file will be saved. Default is "data.json". 1022 1023 Returns: 1024 bool: True if the export is successful, False otherwise. 1025 1026 Raises: 1027 No specific exceptions are raised by this method. 1028 """ 1029 with open(path, "w") as file: 1030 json.dump(self._vault, file, indent=4, cls=JSONEncoder) 1031 return True 1032 return False
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.
1034 def save(self, path: str = None) -> bool: 1035 """ 1036 Save the current state of the ZakatTracker object to a pickle file. 1037 1038 Parameters: 1039 path (str): The path where the pickle file will be saved. If not provided, it will use the default path. 1040 1041 Returns: 1042 bool: True if the save operation is successful, False otherwise. 1043 """ 1044 if path is None: 1045 path = self.path() 1046 with open(path, "wb") as f: 1047 pickle.dump(self._vault, f) 1048 return True 1049 return False
Save the current state of the ZakatTracker object to a pickle file.
Parameters: path (str): The path where the pickle file will be saved. If not provided, it will use the default path.
Returns: bool: True if the save operation is successful, False otherwise.
1051 def load(self, path: str = None) -> bool: 1052 """ 1053 Load the current state of the ZakatTracker object from a pickle file. 1054 1055 Parameters: 1056 path (str): The path where the pickle file is located. If not provided, it will use the default path. 1057 1058 Returns: 1059 bool: True if the load operation is successful, False otherwise. 1060 """ 1061 if path is None: 1062 path = self.path() 1063 if os.path.exists(path): 1064 with open(path, "rb") as f: 1065 self._vault = pickle.load(f) 1066 return True 1067 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.
1069 def import_csv(self, path: str = 'file.csv') -> tuple: 1070 """ 1071 Import transactions from a CSV file. 1072 1073 Parameters: 1074 path (str): The path to the CSV file. Default is 'file.csv'. 1075 1076 Returns: 1077 tuple: A tuple containing the number of transactions created, the number of transactions found in the cache, and a dictionary of bad transactions. 1078 1079 The CSV file should have the following format: 1080 account, desc, value, date 1081 For example: 1082 safe-45, "Some text", 34872, 1988-06-30 00:00:00 1083 1084 The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system. 1085 """ 1086 cache = [] 1087 tmp = "tmp" 1088 try: 1089 with open(tmp, "rb") as f: 1090 cache = pickle.load(f) 1091 except: 1092 pass 1093 date_formats = [ 1094 "%Y-%m-%d %H:%M:%S", 1095 "%Y-%m-%dT%H:%M:%S", 1096 "%Y-%m-%dT%H%M%S", 1097 "%Y-%m-%d", 1098 ] 1099 created, found, bad = 0, 0, {} 1100 with open(path, newline='', encoding="utf-8") as f: 1101 i = 0 1102 for row in csv.reader(f, delimiter=','): 1103 i += 1 1104 hashed = hash(tuple(row)) 1105 if hashed in cache: 1106 found += 1 1107 continue 1108 account = row[0] 1109 desc = row[1] 1110 value = float(row[2]) 1111 date = 0 1112 for time_format in date_formats: 1113 try: 1114 date = self.time(datetime.datetime.strptime(row[3], time_format)) 1115 break 1116 except: 1117 pass 1118 # TODO: not allowed for negative dates 1119 if date == 0 or value == 0: 1120 bad[i] = row 1121 continue 1122 if value > 0: 1123 self.track(value, desc, account, True, date) 1124 elif value < 0: 1125 self.sub(-value, desc, account, date) 1126 created += 1 1127 cache.append(hashed) 1128 with open(tmp, "wb") as f: 1129 pickle.dump(cache, f) 1130 return (created, found, bad)
Import transactions from a CSV file.
Parameters: path (str): The path to the CSV file. Default is 'file.csv'.
Returns: tuple: A tuple containing the number of transactions created, the number of transactions found in the cache, and a dictionary of bad transactions.
The CSV file should have the following format: account, desc, value, date For example: safe-45, "Some text", 34872, 1988-06-30 00:00:00
The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system.
1136 @staticmethod 1137 def DurationFromNanoSeconds(ns: int) -> tuple: 1138 """REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106 1139 Convert NanoSeconds to Human Readable Time Format. 1140 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. 1141 Its symbol is μs, sometimes simplified to us when Unicode is not available. 1142 A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond. 1143 1144 INPUT : ms (AKA: MilliSeconds) 1145 OUTPUT: tuple(string TIMELAPSED, string SPOKENTIME) like format. 1146 OUTPUT Variables: TIMELAPSED, SPOKENTIME 1147 1148 Example Input: DurationFromNanoSeconds(ns) 1149 **"Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds"** 1150 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') 1151 DurationFromNanoSeconds(1234567890123456789012) 1152 """ 1153 μs, ns = divmod(ns, 1000) 1154 ms, μs = divmod(μs, 1000) 1155 s, ms = divmod(ms, 1000) 1156 m, s = divmod(s, 60) 1157 h, m = divmod(m, 60) 1158 d, h = divmod(h, 24) 1159 y, d = divmod(d, 365) 1160 c, y = divmod(y, 100) 1161 n, c = divmod(c, 10) 1162 TIMELAPSED = 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}::{μs:03.0f}::{ns:03.0f}" 1163 SPOKENTIME = 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, {μs: 3d} MicroSeconds, {ns: 3d} NanoSeconds" 1164 return TIMELAPSED, SPOKENTIME
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 TIMELAPSED, string SPOKENTIME) like format. OUTPUT Variables: TIMELAPSED, SPOKENTIME
Example Input: DurationFromNanoSeconds(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') DurationFromNanoSeconds(1234567890123456789012)
1166 @staticmethod 1167 def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime: 1168 """ 1169 Generate a random date between two given dates. 1170 1171 Parameters: 1172 start_date (datetime.datetime): The start date from which to generate a random date. 1173 end_date (datetime.datetime): The end date until which to generate a random date. 1174 1175 Returns: 1176 datetime.datetime: A random date between the start_date and end_date. 1177 """ 1178 time_between_dates = end_date - start_date 1179 days_between_dates = time_between_dates.days 1180 random_number_of_days = random.randrange(days_between_dates) 1181 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.
1183 @staticmethod 1184 def generate_random_csv_file(path: str = "data.csv", count: int = 1000) -> None: 1185 """ 1186 Generate a random CSV file with specified parameters. 1187 1188 Parameters: 1189 path (str): The path where the CSV file will be saved. Default is "data.csv". 1190 count (int): The number of rows to generate in the CSV file. Default is 1000. 1191 1192 Returns: 1193 None. The function generates a CSV file at the specified path with the given count of rows. 1194 Each row contains a randomly generated account, description, value, and date. 1195 The value is randomly generated between 1000 and 100000, and the date is randomly generated between 1950-01-01 and 2023-12-31. 1196 If the row number is not divisible by 13, the value is multiplied by -1. 1197 """ 1198 with open(path, "w", newline="") as csvfile: 1199 writer = csv.writer(csvfile) 1200 for i in range(count): 1201 account = f"acc-{random.randint(1, 1000)}" 1202 desc = f"Some text {random.randint(1, 1000)}" 1203 value = random.randint(1000, 100000) 1204 date = ZakatTracker.generate_random_date(datetime.datetime(1950, 1, 1), datetime.datetime(2023, 12, 31)).strftime("%Y-%m-%d %H:%M:%S") 1205 if not i % 13 == 0: 1206 value *= -1 1207 writer.writerow([account, desc, value, date])
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.
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.
1347 def test(self, debug: bool = False): 1348 1349 try: 1350 1351 assert self._history() 1352 1353 # Not allowed for duplicate transactions in the same account and time 1354 1355 created = ZakatTracker.time() 1356 self.track(100, 'test-1', 'same', True, created) 1357 failed = False 1358 try: 1359 self.track(50, 'test-1', 'same', True, created) 1360 except: 1361 failed = True 1362 assert failed is True 1363 1364 self.reset() 1365 1366 # Always preserve box age during transfer 1367 1368 created = ZakatTracker.time() 1369 series = [ 1370 (30, 4), 1371 (60, 3), 1372 (90, 2), 1373 ] 1374 case = { 1375 30: { 1376 'series': series, 1377 'rest' : 150, 1378 }, 1379 60: { 1380 'series': series, 1381 'rest' : 120, 1382 }, 1383 90: { 1384 'series': series, 1385 'rest' : 90, 1386 }, 1387 180: { 1388 'series': series, 1389 'rest' : 0, 1390 }, 1391 270: { 1392 'series': series, 1393 'rest' : -90, 1394 }, 1395 360: { 1396 'series': series, 1397 'rest' : -180, 1398 }, 1399 } 1400 1401 selected_time = ZakatTracker.time() - ZakatTracker.TimeCycle() 1402 1403 for total in case: 1404 for x in case[total]['series']: 1405 self.track(x[0], f"test-{x} ages", 'ages', True, selected_time * x[1]) 1406 1407 refs = self.transfer(total, 'ages', 'future', 'Zakat Movement') 1408 1409 if debug: 1410 print('refs', refs) 1411 1412 ages_cache_balance = self.balance('ages') 1413 ages_fresh_balance = self.balance('ages', False) 1414 rest = case[total]['rest'] 1415 if debug: 1416 print('source', ages_cache_balance, ages_fresh_balance, rest) 1417 assert ages_cache_balance == rest 1418 assert ages_fresh_balance == rest 1419 1420 future_cache_balance = self.balance('future') 1421 future_fresh_balance = self.balance('future', False) 1422 if debug: 1423 print('target', future_cache_balance, future_fresh_balance, total) 1424 print('refs', refs) 1425 assert future_cache_balance == total 1426 assert future_fresh_balance == total 1427 1428 for ref in self._vault['account']['ages']['box']: 1429 ages_capital = self._vault['account']['ages']['box'][ref]['capital'] 1430 ages_rest = self._vault['account']['ages']['box'][ref]['rest'] 1431 future_capital = 0 1432 if ref in self._vault['account']['future']['box']: 1433 future_capital = self._vault['account']['future']['box'][ref]['capital'] 1434 future_rest = 0 1435 if ref in self._vault['account']['future']['box']: 1436 future_rest = self._vault['account']['future']['box'][ref]['rest'] 1437 if ages_capital != 0 and future_capital != 0 and future_rest != 0: 1438 if debug: 1439 print('================================================================') 1440 print('ages', ages_capital, ages_rest) 1441 print('future', future_capital, future_rest) 1442 if ages_rest == 0: 1443 assert ages_capital == future_capital 1444 elif ages_rest < 0: 1445 assert -ages_capital == future_capital 1446 elif ages_rest > 0: 1447 assert ages_capital == ages_rest + future_capital 1448 self.reset() 1449 assert len(self._vault['history']) == 0 1450 1451 assert self._history() 1452 assert self._history(False) is False 1453 assert self._history() is False 1454 assert self._history(True) 1455 assert self._history() 1456 1457 self._test_core(True, debug) 1458 self._test_core(False, debug) 1459 1460 transaction = [ 1461 ( 1462 20, 'wallet', 1, 800, 800, 800, 4, 5, 1463 -85, -85, -85, 6, 7, 1464 ), 1465 ( 1466 750, 'wallet', 'safe', 50, 50, 50, 4, 6, 1467 750, 750, 750, 1, 1, 1468 ), 1469 ( 1470 600, 'safe', 'bank', 150, 150, 150, 1, 2, 1471 600, 600, 600, 1, 1, 1472 ), 1473 ] 1474 for z in transaction: 1475 self.lock() 1476 x = z[1] 1477 y = z[2] 1478 self.transfer(z[0], x, y, 'test-transfer') 1479 assert self.balance(x) == z[3] 1480 xx = self.accounts()[x] 1481 assert xx == z[3] 1482 assert self.balance(x, False) == z[4] 1483 assert xx == z[4] 1484 1485 l = self._vault['account'][x]['log'] 1486 s = 0 1487 for i in l: 1488 s += l[i]['value'] 1489 if debug: 1490 print('s', s, 'z[5]', z[5]) 1491 assert s == z[5] 1492 1493 assert self.box_size(x) == z[6] 1494 assert self.log_size(x) == z[7] 1495 1496 yy = self.accounts()[y] 1497 assert self.balance(y) == z[8] 1498 assert yy == z[8] 1499 assert self.balance(y, False) == z[9] 1500 assert yy == z[9] 1501 1502 l = self._vault['account'][y]['log'] 1503 s = 0 1504 for i in l: 1505 s += l[i]['value'] 1506 assert s == z[10] 1507 1508 assert self.box_size(y) == z[11] 1509 assert self.log_size(y) == z[12] 1510 1511 if debug: 1512 pp().pprint(self.check(2.17)) 1513 1514 assert not self.nolock() 1515 history_count = len(self._vault['history']) 1516 if debug: 1517 print('history-count', history_count) 1518 assert history_count == 11 1519 assert not self.free(ZakatTracker.time()) 1520 assert self.free(self.lock()) 1521 assert self.nolock() 1522 assert len(self._vault['history']) == 11 1523 1524 # storage 1525 1526 _path = self.path('test.pickle') 1527 if os.path.exists(_path): 1528 os.remove(_path) 1529 self.save() 1530 assert os.path.getsize(_path) > 0 1531 self.reset() 1532 assert self.recall(False, debug) is False 1533 self.load() 1534 assert self._vault['account'] is not None 1535 1536 # recall 1537 1538 assert self.nolock() 1539 assert len(self._vault['history']) == 11 1540 assert self.recall(False, debug) is True 1541 assert len(self._vault['history']) == 10 1542 assert self.recall(False, debug) is True 1543 assert len(self._vault['history']) == 9 1544 1545 # csv 1546 1547 _path = "test.csv" 1548 count = 1000 1549 if os.path.exists(_path): 1550 os.remove(_path) 1551 self.generate_random_csv_file(_path, count) 1552 assert os.path.getsize(_path) > 0 1553 tmp = "tmp" 1554 if os.path.exists(tmp): 1555 os.remove(tmp) 1556 (created, found, bad) = self.import_csv(_path) 1557 bad_count = len(bad) 1558 if debug: 1559 print(f"csv-imported: ({created}, {found}, {bad_count})") 1560 tmp_size = os.path.getsize(tmp) 1561 assert tmp_size > 0 1562 assert created + found + bad_count == count 1563 assert created == count 1564 assert bad_count == 0 1565 (created_2, found_2, bad_2) = self.import_csv(_path) 1566 bad_2_count = len(bad_2) 1567 if debug: 1568 print(f"csv-imported: ({created_2}, {found_2}, {bad_2_count})") 1569 print(bad) 1570 assert tmp_size == os.path.getsize(tmp) 1571 assert created_2 + found_2 + bad_2_count == count 1572 assert created == found_2 1573 assert bad_count == bad_2_count 1574 assert found_2 == count 1575 assert bad_2_count == 0 1576 assert created_2 == 0 1577 1578 # payment parts 1579 1580 positive_parts = self.build_payment_parts(100, positive_only=True) 1581 assert self.check_payment_parts(positive_parts) != 0 1582 assert self.check_payment_parts(positive_parts) != 0 1583 all_parts = self.build_payment_parts(300, positive_only= False) 1584 assert self.check_payment_parts(all_parts) != 0 1585 assert self.check_payment_parts(all_parts) != 0 1586 if debug: 1587 pp().pprint(positive_parts) 1588 pp().pprint(all_parts) 1589 # dynamic discount 1590 suite = [] 1591 count = 3 1592 for exceed in [False, True]: 1593 case = [] 1594 for parts in [positive_parts, all_parts]: 1595 part = parts.copy() 1596 demand = part['demand'] 1597 if debug: 1598 print(demand, part['total']) 1599 i = 0 1600 z = demand / count 1601 cp = { 1602 'account': {}, 1603 'demand': demand, 1604 'exceed': exceed, 1605 'total': part['total'], 1606 } 1607 for x, y in part['account'].items(): 1608 if exceed and z <= demand: 1609 i += 1 1610 y['part'] = z 1611 if debug: 1612 print(exceed, y) 1613 cp['account'][x] = y 1614 case.append(y) 1615 elif not exceed and y['balance'] >= z: 1616 i += 1 1617 y['part'] = z 1618 if debug: 1619 print(exceed, y) 1620 cp['account'][x] = y 1621 case.append(y) 1622 if i >= count: 1623 break 1624 if len(cp['account'][x]) > 0: 1625 suite.append(cp) 1626 if debug: 1627 print('suite', len(suite)) 1628 for case in suite: 1629 if debug: 1630 print(case) 1631 result = self.check_payment_parts(case) 1632 if debug: 1633 print('check_payment_parts', result) 1634 assert result == 0 1635 1636 report = self.check(2.17, None, debug) 1637 (valid, brief, plan) = report 1638 if debug: 1639 print('valid', valid) 1640 assert self.zakat(report, parts=suite, debug=debug) 1641 1642 assert self.export_json("1000-transactions-test.json") 1643 assert self.save("1000-transactions-test.pickle") 1644 1645 # check & zakat 1646 1647 cases = [ 1648 (1000, 'safe', ZakatTracker.time()-ZakatTracker.TimeCycle(), [ 1649 {'safe': {0: {'below_nisab': 25}}}, 1650 ], False), 1651 (2000, 'safe', ZakatTracker.time()-ZakatTracker.TimeCycle(), [ 1652 {'safe': {0: {'count': 1, 'total': 50}}}, 1653 ], True), 1654 (10000, 'cave', ZakatTracker.time()-(ZakatTracker.TimeCycle()*3), [ 1655 {'cave': {0: {'count': 3, 'total': 731.40625}}}, 1656 ], True), 1657 ] 1658 for case in cases: 1659 if debug: 1660 print("############# check #############") 1661 self.reset() 1662 self.track(case[0], 'test-check', case[1], True, case[2]) 1663 1664 assert self.nolock() 1665 assert len(self._vault['history']) == 1 1666 assert self.lock() 1667 assert not self.nolock() 1668 report = self.check(2.17, None, debug) 1669 (valid, brief, plan) = report 1670 assert valid == case[4] 1671 if debug: 1672 print(brief) 1673 assert case[0] == brief[0] 1674 assert case[0] == brief[1] 1675 1676 if debug: 1677 pp().pprint(plan) 1678 1679 for x in plan: 1680 assert case[1] == x 1681 if 'total' in case[3][0][x][0].keys(): 1682 assert case[3][0][x][0]['total'] == brief[2] 1683 assert plan[x][0]['total'] == case[3][0][x][0]['total'] 1684 assert plan[x][0]['count'] == case[3][0][x][0]['count'] 1685 else: 1686 assert plan[x][0]['below_nisab'] == case[3][0][x][0]['below_nisab'] 1687 if debug: 1688 pp().pprint(report) 1689 result = self.zakat(report, debug=debug) 1690 if debug: 1691 print('zakat-result', result, case[4]) 1692 assert result == case[4] 1693 report = self.check(2.17, None, debug) 1694 (valid, brief, plan) = report 1695 assert valid is False 1696 1697 assert len(self._vault['history']) == 2 1698 assert not self.nolock() 1699 assert self.recall(False, debug) is False 1700 self.free(self.lock()) 1701 assert self.nolock() 1702 assert len(self._vault['history']) == 2 1703 assert self.recall(False, debug) is True 1704 assert len(self._vault['history']) == 1 1705 1706 assert self.nolock() 1707 assert len(self._vault['history']) == 1 1708 1709 assert self.recall(False, debug) is True 1710 assert len(self._vault['history']) == 0 1711 1712 assert len(self._vault['account']) == 0 1713 assert len(self._vault['history']) == 0 1714 assert len(self._vault['report']) == 0 1715 assert self.nolock() 1716 except: 1717 pp().pprint(self._vault) 1718 raise
68class Action(Enum): 69 CREATE = auto() 70 TRACK = auto() 71 LOG = auto() 72 SUB = auto() 73 ADD_FILE = auto() 74 REMOVE_FILE = auto() 75 REPORT = auto() 76 ZAKAT = auto()
78class JSONEncoder(json.JSONEncoder): 79 def default(self, obj): 80 if isinstance(obj, Action) or isinstance(obj, MathOperation): 81 return obj.name # Serialize as the enum member's name 82 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
).
79 def default(self, obj): 80 if isinstance(obj, Action) or isinstance(obj, MathOperation): 81 return obj.name # Serialize as the enum member's name 82 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)