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