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