zakat
xxx

"رَبَّنَا افْتَحْ بَيْنَنَا وَبَيْنَ قَوْمِنَا بِالْحَقِّ وَأَنتَ خَيْرُ الْفَاتِحِينَ (89)" -- سورة الأعراف

 _____     _         _     _     _ _                          
|__  /__ _| | ____ _| |_  | |   (_) |__  _ __ __ _ _ __ _   _ 
  / // _` | |/ / _` | __| | |   | | '_ \| '__/ _` | '__| | | |
 / /| (_| |   < (_| | |_  | |___| | |_) | | | (_| | |  | |_| |
/____\__,_|_|\_\__,_|\__| |_____|_|_.__/|_|  \__,_|_|   \__, |
... Never Trust, Always Verify ...                       |___/ 

This library provides the ZakatLibrary classes, functions for tracking and calculating Zakat.


# ☪️ Zakat: A Python Library for Islamic Financial Management ** **We must pay Zakat if the remaining of every transaction reaches the Haul and Nisab limits** ** ###### [PROJECT UNDER ACTIVE R&D]

CodeRabbit Pull Request Reviews ar

Zakat is a user-friendly Python library designed to simplify the tracking and calculation of Zakat, a fundamental pillar of Islamic finance. Whether you're an individual or an organization, Zakat provides the tools to accurately manage your Zakat obligations.

Get Started:

Install the Zakat library using pip:

pip install zakat
Testing
python -c "import zakat, sys; sys.exit(zakat.test())"
Example
from zakat import tracker, time
from datetime import datetime
from dateutil.relativedelta import relativedelta

ledger = tracker(':memory:') # or './zakat_db'

# Add balance (track a transaction)
ledger.track(10000, "Initial deposit") # default account is 1
# or
pocket_account_id = ledger.create_account("pocket")
ledger.track(
    10000, # amount
    "Initial deposit", # description
    account=pocket_account_id,
    created_time_ns=time(datetime.now()),
)
# or old transaction
box_ref = ledger.track(
    10000, # amount
    "Initial deposit", # description
    account=ledger.create_account("bunker"),
    created_time_ns=time(datetime.now() - relativedelta(years=1)),
)

# Note: If any account does not exist it will be automatically created.

# Subtract balance
ledger.subtract(500, "Plummer maintenance expense") # default account is 1
# or
subtract_report = ledger.subtract(
    500, # amount
    "Internet monthly subscription", # description
    account=pocket_account_id,
    created_time_ns=time(datetime.now()),
)

# Transfer balance
bank_account_id = ledger.create_account("bank")
ledger.transfer(100, pocket_account_id, bank_account_id) # default time is now
# or
transfer_report = ledger.transfer(
    100,
    from_account=pocket_account_id,
    to_account=ledger.create_account("safe"),
    created_time_ns=time(datetime.now()),
)
# or
bank_usd_account_id = ledger.create_account("bank (USD)")
ledger.exchange(bank_usd_account_id, rate=3.75) # suppose current currency is SAR rate=1
ledger.transfer(375, pocket_account_id, bank_usd_account_id) # This time exchange rates considered

# Note: The age of balances in all transactions are preserved while transfering.

# Estimate Zakat (generate a report)
zakat_report = ledger.check(silver_gram_price=2.5)


# Perform Zakat (Apply Zakat)
# discount from the same accounts if Zakat applicable individually or collectively
ledger.zakat(zakat_report) # --> True
# or Collect all Zakat and discount from selected accounts
parts = ledger.build_payment_parts(zakat_report.summary.total_zakatable_amount)
# modify `parts` to distribute your Zakat on selected accounts
ledger.zakat(zakat_report, parts) # --> False
Vault data structure:

The main data storage file system on disk is JSON format, but it is shown here in JSON format for data generated by the example above (note: times will be different if re-applied by yourself):

{
  "account": {
    "1": {
      "balance": 950000,
      "created": 63879017256646705000,
      "name": "",
      "box": {
        "63879017256646705152": {
          "capital": 1000000,
          "rest": 950000,
          "zakat": {
            "count": 0,
            "last": 0,
            "total": 0
          }
        }
      },
      "count": 2,
      "log": {
        "63879017256646705152": {
          "value": 1000000,
          "desc": "Initial deposit",
          "ref": null,
          "file": {}
        },
        "63879017256648155136": {
          "value": -50000,
          "desc": "Plummer maintenance expense",
          "ref": null,
          "file": {}
        }
      },
      "hide": false,
      "zakatable": true
    },
    "63879017256647188480": {
      "balance": 892500,
      "created": 63879017256647230000,
      "name": "pocket",
      "box": {
        "63879017256647409664": {
          "capital": 1000000,
          "rest": 892500,
          "zakat": {
            "count": 0,
            "last": 0,
            "total": 0
          }
        }
      },
      "count": 5,
      "log": {
        "63879017256647409664": {
          "value": 1000000,
          "desc": "Initial deposit",
          "ref": null,
          "file": {}
        },
        "63879017256648392704": {
          "value": -50000,
          "desc": "Internet monthly subscription",
          "ref": null,
          "file": {}
        },
        "63879017256648802304": {
          "value": -10000,
          "desc": "",
          "ref": null,
          "file": {}
        },
        "63879017256649555968": {
          "value": -10000,
          "desc": "",
          "ref": null,
          "file": {}
        },
        "63879017256650096640": {
          "value": -37500,
          "desc": "",
          "ref": null,
          "file": {}
        }
      },
      "hide": false,
      "zakatable": true
    },
    "63879017256647622656": {
      "balance": 975000,
      "created": 63879017256647655000,
      "name": "bunker",
      "box": {
        "63847481256647794688": {
          "capital": 1000000,
          "rest": 975000,
          "zakat": {
            "count": 1,
            "last": 63879017256650820000,
            "total": 25000
          }
        }
      },
      "count": 2,
      "log": {
        "63847481256647794688": {
          "value": 1000000,
          "desc": "Initial deposit",
          "ref": null,
          "file": {}
        },
        "63879017256650932224": {
          "value": -25000,
          "desc": "zakat-زكاة",
          "ref": 63847481256647795000,
          "file": {}
        }
      },
      "hide": false,
      "zakatable": true
    },
    "63879017256648605696": {
      "balance": 10000,
      "created": 63879017256648640000,
      "name": "bank",
      "box": {
        "63879017256647409664": {
          "capital": 10000,
          "rest": 10000,
          "zakat": {
            "count": 0,
            "last": 0,
            "total": 0
          }
        }
      },
      "count": 1,
      "log": {
        "63879017256647409664": {
          "value": 10000,
          "desc": "",
          "ref": null,
          "file": {}
        }
      },
      "hide": false,
      "zakatable": true
    },
    "63879017256649383936": {
      "balance": 10000,
      "created": 63879017256649425000,
      "name": "safe",
      "box": {
        "63879017256647409664": {
          "capital": 10000,
          "rest": 10000,
          "zakat": {
            "count": 0,
            "last": 0,
            "total": 0
          }
        }
      },
      "count": 1,
      "log": {
        "63879017256647409664": {
          "value": 10000,
          "desc": "",
          "ref": null,
          "file": {}
        }
      },
      "hide": false,
      "zakatable": true
    },
    "63879017256649859072": {
      "balance": 10000,
      "created": 63879017256649890000,
      "name": "bank (USD)",
      "box": {
        "63879017256647409664": {
          "capital": 10000,
          "rest": 10000,
          "zakat": {
            "count": 0,
            "last": 0,
            "total": 0
          }
        }
      },
      "count": 1,
      "log": {
        "63879017256647409664": {
          "value": 10000,
          "desc": "",
          "ref": null,
          "file": {}
        }
      },
      "hide": false,
      "zakatable": true
    }
  },
  "exchange": {
    "63879017256649859072": {
      "63879017256649998336": {
        "rate": 3.75,
        "description": null,
        "time": 63879017256650000000
      }
    }
  },
  "history": {
    "63879017256646787072": {
      "63879017256646885376": {
        "action": "CREATE",
        "account": "1",
        "ref": null,
        "file": null,
        "key": null,
        "value": null,
        "math": null
      },
      "63879017256647065600": {
        "action": "LOG",
        "account": "1",
        "ref": 63879017256646705000,
        "file": null,
        "key": null,
        "value": 1000000,
        "math": null
      },
      "63879017256647139328": {
        "action": "TRACK",
        "account": "1",
        "ref": 63879017256646705000,
        "file": null,
        "key": null,
        "value": 1000000,
        "math": null
      }
    },
    "63879017256647254016": {
      "63879017256647303168": {
        "action": "CREATE",
        "account": "63879017256647188480",
        "ref": null,
        "file": null,
        "key": null,
        "value": null,
        "math": null
      }
    },
    "63879017256647352320": {
      "63879017256647385088": {
        "action": "NAME",
        "account": "63879017256647188480",
        "ref": null,
        "file": null,
        "key": null,
        "value": "",
        "math": null
      }
    },
    "63879017256647442432": {
      "63879017256647540736": {
        "action": "LOG",
        "account": "63879017256647188480",
        "ref": 63879017256647410000,
        "file": null,
        "key": null,
        "value": 1000000,
        "math": null
      },
      "63879017256647589888": {
        "action": "TRACK",
        "account": "63879017256647188480",
        "ref": 63879017256647410000,
        "file": null,
        "key": null,
        "value": 1000000,
        "math": null
      }
    },
    "63879017256647680000": {
      "63879017256647712768": {
        "action": "CREATE",
        "account": "63879017256647622656",
        "ref": null,
        "file": null,
        "key": null,
        "value": null,
        "math": null
      }
    },
    "63879017256647745536": {
      "63879017256647778304": {
        "action": "NAME",
        "account": "63879017256647622656",
        "ref": null,
        "file": null,
        "key": null,
        "value": "",
        "math": null
      }
    },
    "63879017256647999488": {
      "63879017256648081408": {
        "action": "LOG",
        "account": "63879017256647622656",
        "ref": 63847481256647795000,
        "file": null,
        "key": null,
        "value": 1000000,
        "math": null
      },
      "63879017256648122368": {
        "action": "TRACK",
        "account": "63879017256647622656",
        "ref": 63847481256647795000,
        "file": null,
        "key": null,
        "value": 1000000,
        "math": null
      }
    },
    "63879017256648187904": {
      "63879017256648294400": {
        "action": "LOG",
        "account": "1",
        "ref": 63879017256648155000,
        "file": null,
        "key": null,
        "value": -50000,
        "math": null
      },
      "63879017256648351744": {
        "action": "SUBTRACT",
        "account": "1",
        "ref": 63879017256646705000,
        "file": null,
        "key": null,
        "value": 50000,
        "math": null
      }
    },
    "63879017256648425472": {
      "63879017256648531968": {
        "action": "LOG",
        "account": "63879017256647188480",
        "ref": 63879017256648390000,
        "file": null,
        "key": null,
        "value": -50000,
        "math": null
      },
      "63879017256648564736": {
        "action": "SUBTRACT",
        "account": "63879017256647188480",
        "ref": 63879017256647410000,
        "file": null,
        "key": null,
        "value": 50000,
        "math": null
      }
    },
    "63879017256648663040": {
      "63879017256648704000": {
        "action": "CREATE",
        "account": "63879017256648605696",
        "ref": null,
        "file": null,
        "key": null,
        "value": null,
        "math": null
      }
    },
    "63879017256648736768": {
      "63879017256648761344": {
        "action": "NAME",
        "account": "63879017256648605696",
        "ref": null,
        "file": null,
        "key": null,
        "value": "",
        "math": null
      }
    },
    "63879017256648818688": {
      "63879017256649031680": {
        "action": "LOG",
        "account": "63879017256647188480",
        "ref": 63879017256648800000,
        "file": null,
        "key": null,
        "value": -10000,
        "math": null
      },
      "63879017256649072640": {
        "action": "SUBTRACT",
        "account": "63879017256647188480",
        "ref": 63879017256647410000,
        "file": null,
        "key": null,
        "value": 10000,
        "math": null
      },
      "63879017256649285632": {
        "action": "LOG",
        "account": "63879017256648605696",
        "ref": 63879017256647410000,
        "file": null,
        "key": null,
        "value": 10000,
        "math": null
      },
      "63879017256649334784": {
        "action": "TRACK",
        "account": "63879017256648605696",
        "ref": 63879017256647410000,
        "file": null,
        "key": null,
        "value": 10000,
        "math": null
      }
    },
    "63879017256649441280": {
      "63879017256649482240": {
        "action": "CREATE",
        "account": "63879017256649383936",
        "ref": null,
        "file": null,
        "key": null,
        "value": null,
        "math": null
      }
    },
    "63879017256649515008": {
      "63879017256649539584": {
        "action": "NAME",
        "account": "63879017256649383936",
        "ref": null,
        "file": null,
        "key": null,
        "value": "",
        "math": null
      }
    },
    "63879017256649580544": {
      "63879017256649662464": {
        "action": "LOG",
        "account": "63879017256647188480",
        "ref": 63879017256649560000,
        "file": null,
        "key": null,
        "value": -10000,
        "math": null
      },
      "63879017256649695232": {
        "action": "SUBTRACT",
        "account": "63879017256647188480",
        "ref": 63879017256647410000,
        "file": null,
        "key": null,
        "value": 10000,
        "math": null
      },
      "63879017256649801728": {
        "action": "LOG",
        "account": "63879017256649383936",
        "ref": 63879017256647410000,
        "file": null,
        "key": null,
        "value": 10000,
        "math": null
      },
      "63879017256649826304": {
        "action": "TRACK",
        "account": "63879017256649383936",
        "ref": 63879017256647410000,
        "file": null,
        "key": null,
        "value": 10000,
        "math": null
      }
    },
    "63879017256649900032": {
      "63879017256649932800": {
        "action": "CREATE",
        "account": "63879017256649859072",
        "ref": null,
        "file": null,
        "key": null,
        "value": null,
        "math": null
      }
    },
    "63879017256649957376": {
      "63879017256649973760": {
        "action": "NAME",
        "account": "63879017256649859072",
        "ref": null,
        "file": null,
        "key": null,
        "value": "",
        "math": null
      }
    },
    "63879017256650022912": {
      "63879017256650047488": {
        "action": "EXCHANGE",
        "account": "63879017256649859072",
        "ref": 63879017256650000000,
        "file": null,
        "key": null,
        "value": 3.75,
        "math": null
      }
    },
    "63879017256650121216": {
      "63879017256650203136": {
        "action": "LOG",
        "account": "63879017256647188480",
        "ref": 63879017256650100000,
        "file": null,
        "key": null,
        "value": -37500,
        "math": null
      },
      "63879017256650227712": {
        "action": "SUBTRACT",
        "account": "63879017256647188480",
        "ref": 63879017256647410000,
        "file": null,
        "key": null,
        "value": 37500,
        "math": null
      },
      "63879017256650334208": {
        "action": "LOG",
        "account": "63879017256649859072",
        "ref": 63879017256647410000,
        "file": null,
        "key": null,
        "value": 10000,
        "math": null
      },
      "63879017256650366976": {
        "action": "TRACK",
        "account": "63879017256649859072",
        "ref": 63879017256647410000,
        "file": null,
        "key": null,
        "value": 10000,
        "math": null
      }
    },
    "63879017256650760192": {
      "63879017256650801152": {
        "action": "REPORT",
        "account": null,
        "ref": 63879017256650785000,
        "file": null,
        "key": null,
        "value": null,
        "math": null
      },
      "63879017256650866688": {
        "action": "ZAKAT",
        "account": "63879017256647622656",
        "ref": 63847481256647795000,
        "file": null,
        "key": "last",
        "value": 0,
        "math": "EQUAL"
      },
      "63879017256650891264": {
        "action": "ZAKAT",
        "account": "63879017256647622656",
        "ref": 63847481256647795000,
        "file": null,
        "key": "total",
        "value": 25000,
        "math": "ADDITION"
      },
      "63879017256650907648": {
        "action": "ZAKAT",
        "account": "63879017256647622656",
        "ref": 63847481256647795000,
        "file": null,
        "key": "count",
        "value": 1,
        "math": "ADDITION"
      },
      "63879017256650964992": {
        "action": "LOG",
        "account": "63879017256647622656",
        "ref": 63879017256650930000,
        "file": null,
        "key": null,
        "value": -25000,
        "math": null
      }
    }
  },
  "lock": null,
  "report": {
    "63879017256650784768": {
      "valid": true,
      "summary": {
        "total_wealth": 2900000,
        "num_wealth_items": 6,
        "num_zakatable_items": 1,
        "total_zakatable_amount": 1000000,
        "total_zakat_due": 25000
      },
      "plan": {
        "63879017256647622656": [
          {
            "box": {
              "capital": 1000000,
              "rest": 975000,
              "zakat": {
                "count": 1,
                "last": 63879017256650820000,
                "total": 25000
              }
            },
            "log": {
              "value": 1000000,
              "desc": "Initial deposit",
              "ref": null,
              "file": {}
            },
            "exchange": {
              "rate": 1,
              "description": null,
              "time": 63879017256650555000
            },
            "below_nisab": false,
            "total": 25000,
            "count": 1,
            "ref": 63847481256647795000
          }
        ]
      }
    }
  }
}

Key Features:

  • Transaction Tracking: Easily record both income and expenses with detailed descriptions, ensuring comprehensive financial records.

  • Automated Zakat Calculation: Automatically calculate Zakat due based on the Nisab (minimum threshold), Haul (time cycles) and the current market price of silver, simplifying compliance with Islamic financial principles.

  • Customizable "Nisab": Set your own "Nisab" value based on your preferred calculation method or personal financial situation.

  • Customizable "Haul": Set your own "Haul" cycle based on your preferred calender method or personal financial situation.

  • Multiple Accounts: Manage Zakat for different assets or accounts separately for greater financial clarity.

  • Import/Export: Seamlessly import transaction data from CSV files [experimental] and export calculated Zakat reports in JSON format for further analysis or record-keeping.

  • Data Persistence: Securely save and load your Zakat tracker data for continued use across sessions.

  • History Tracking: Optionally enable a detailed history of actions for transparency and review (can be disabled optionally).

Benefits:

  • Accurate Zakat Calculation: Ensure precise calculation of Zakat obligations, promoting financial responsibility and spiritual well-being.

  • Streamlined Financial Management: Simplify the management of your finances by keeping track of transactions and Zakat calculations in one place.

  • Enhanced Transparency: Maintain a clear record of your financial activities and Zakat payments for greater accountability and peace of mind.

  • User-Friendly: Easily navigate through the library's intuitive interface and functionalities, even without extensive technical knowledge.

Customizable:

  • Tailor the library's settings (e.g., Nisab value and Haul cycles) to your specific needs and preferences.

Who Can Benefit:

  • Individuals: Effectively manage personal finances and fulfill Zakat obligations.

  • Organizations: Streamline Zakat calculation and distribution for charitable projects and initiatives.

  • Islamic Financial Institutions: Integrate Zakat into existing systems for enhanced financial management and reporting.

Documentation

Videos:

Explore the documentation, source code and examples to begin tracking your Zakat and achieving financial peace of mind in accordance with Islamic principles.

 1"""
 2"رَبَّنَا افْتَحْ بَيْنَنَا وَبَيْنَ قَوْمِنَا بِالْحَقِّ وَأَنتَ خَيْرُ الْفَاتِحِينَ (89)" -- سورة الأعراف
 3
 4```
 5 _____     _         _     _     _ _                          
 6|__  /__ _| | ____ _| |_  | |   (_) |__  _ __ __ _ _ __ _   _ 
 7  / // _` | |/ / _` | __| | |   | | '_ \| '__/ _` | '__| | | |
 8 / /| (_| |   < (_| | |_  | |___| | |_) | | | (_| | |  | |_| |
 9/____\__,_|_|\_\__,_|\__| |_____|_|_.__/|_|  \__,_|_|   \__, |
10... Never Trust, Always Verify ...                       |___/ 
11```
12
13This library provides the ZakatLibrary classes, functions for tracking and calculating Zakat.
14
15.. include:: ../README.md
16"""
17# Importing necessary classes and functions from the main module
18from zakat.zakat_tracker import (
19    Time,
20    ZakatTracker,
21    AccountName,
22    Timestamp,
23    Box,
24    Log,
25    Account,
26    Exchange,
27    History,
28    Vault,
29    AccountPaymentPart,
30    PaymentParts,
31    SubtractAge,
32    SubtractAges,
33    SubtractReport,
34    TransferTime,
35    TransferTimes,
36    TransferRecord,
37    TransferReport,
38    BoxPlan,
39    ZakatPlan,
40    ZakatReportStatistics,
41    ZakatReport,
42    test,
43    Action,
44    JSONEncoder,
45    JSONDecoder,
46    MathOperation,
47    WeekDay,
48)
49
50from zakat.file_server import (
51    start_file_server,
52    find_available_port,
53    FileType,
54)
55
56# Shortcuts
57time = Time.time
58time_to_datetime = Time.time_to_datetime
59tracker = ZakatTracker
60
61# Version information for the module
62__version__ = ZakatTracker.Version()
63__all__ = [
64    "Time",
65    "time",
66    "time_to_datetime",
67    "tracker",
68    "ZakatTracker",
69    "AccountName",
70    "Timestamp",
71    "Box",
72    "Log",
73    "Account",
74    "Exchange",
75    "History",
76    "Vault",
77    "AccountPaymentPart",
78    "PaymentParts",
79    "SubtractAge",
80    "SubtractAges",
81    "SubtractReport",
82    "TransferTime",
83    "TransferTimes",
84    "TransferRecord",
85    "TransferReport",
86    "BoxPlan",
87    "ZakatPlan",
88    "ZakatReportStatistics",
89    "ZakatReport",
90    "test",
91    "Action",
92    "JSONEncoder",
93    "JSONDecoder",
94    "MathOperation",
95    "WeekDay",
96    "start_file_server",
97    "find_available_port",
98    "FileType",
99]
class Time:
658class Time:
659    """
660    Utility class for generating and manipulating nanosecond-precision timestamps.
661
662    This class provides static methods for converting between datetime objects and
663    nanosecond-precision timestamps, ensuring uniqueness and monotonicity.
664    """
665    __last_time_ns = None
666    __time_diff_ns = None
667
668    @staticmethod
669    def minimum_time_diff_ns() -> tuple[int, int]:
670        """
671        Calculates the minimum time difference between two consecutive calls to
672        `Time._time()` in nanoseconds.
673
674        This method is used internally to determine the minimum granularity of
675        time measurements within the system.
676
677        Returns:
678        - tuple[int, int]:
679            - The minimum time difference in nanoseconds.
680            - The number of iterations required to measure the difference.
681        """
682        i = 0
683        x = y = Time._time()
684        while x == y:
685            y = Time._time()
686            i += 1
687        return y - x, i
688
689    @staticmethod
690    def _time(now: Optional[datetime.datetime] = None) -> Timestamp:
691        """
692        Internal method to generate a nanosecond-precision timestamp from a datetime object.
693
694        Parameters:
695        - now (datetime.datetime, optional): The datetime object to generate the timestamp from.
696        If not provided, the current datetime is used.
697
698        Returns:
699        - int: The timestamp in nanoseconds since the epoch (January 1, 1AD).
700        """
701        if now is None:
702            now = datetime.datetime.now()
703        ns_in_day = (now - now.replace(
704            hour=0,
705            minute=0,
706            second=0,
707            microsecond=0,
708        )).total_seconds() * 10 ** 9
709        return Timestamp(now.toordinal() * 86_400_000_000_000 + ns_in_day)
710
711    @staticmethod
712    def time(now: Optional[datetime.datetime] = None) -> Timestamp:
713        """
714        Generates a unique, monotonically increasing timestamp based on the provided
715        datetime object or the current datetime.
716
717        This method ensures that timestamps are unique even if called in rapid succession
718        by introducing a small delay if necessary, based on the system's minimum
719        time resolution.
720
721        Parameters:
722        - now (datetime.datetime, optional): The datetime object to generate the timestamp from.
723        If not provided, the current datetime is used.
724
725        Returns:
726        - Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD).
727        """
728        new_time = Time._time(now)
729        if Time.__last_time_ns is None:
730            Time.__last_time_ns = new_time
731            return new_time
732        while new_time == Time.__last_time_ns:
733            if Time.__time_diff_ns is None:
734                diff, _ = Time.minimum_time_diff_ns()
735                Time.__time_diff_ns = math.ceil(diff)
736            time.sleep(Time.__time_diff_ns / 1_000_000_000)
737            new_time = Time._time()
738        Time.__last_time_ns = new_time
739        return new_time
740
741    @staticmethod
742    def time_to_datetime(ordinal_ns: Timestamp) -> datetime.datetime:
743        """
744        Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD)
745        back to a datetime object.
746
747        Parameters:
748        - ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD).
749
750        Returns:
751        - datetime.datetime: The corresponding datetime object.
752        """
753        d = datetime.datetime.fromordinal(ordinal_ns // 86_400_000_000_000)
754        t = datetime.timedelta(seconds=(ordinal_ns % 86_400_000_000_000) // 10 ** 9)
755        return datetime.datetime.combine(d, datetime.time()) + t
756
757    @staticmethod
758    def duration_from_nanoseconds(ns: int,
759                                  show_zeros_in_spoken_time: bool = False,
760                                  spoken_time_separator=',',
761                                  millennia: str = 'Millennia',
762                                  century: str = 'Century',
763                                  years: str = 'Years',
764                                  days: str = 'Days',
765                                  hours: str = 'Hours',
766                                  minutes: str = 'Minutes',
767                                  seconds: str = 'Seconds',
768                                  milli_seconds: str = 'MilliSeconds',
769                                  micro_seconds: str = 'MicroSeconds',
770                                  nano_seconds: str = 'NanoSeconds',
771                                  ) -> tuple:
772        """
773        REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
774        Convert NanoSeconds to Human Readable Time Format.
775        A NanoSeconds is a unit of time in the International System of Units (SI) equal
776        to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
777        Its symbol is μs, sometimes simplified to us when Unicode is not available.
778        A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
779
780        INPUT : ms (AKA: MilliSeconds)
781        OUTPUT: tuple(string time_lapsed, string spoken_time) like format.
782        OUTPUT Variables: time_lapsed, spoken_time
783
784        Example  Input: duration_from_nanoseconds(ns)
785        **'Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds'**
786        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')
787        duration_from_nanoseconds(1234567890123456789012)
788        """
789        us, ns = divmod(ns, 1000)
790        ms, us = divmod(us, 1000)
791        s, ms = divmod(ms, 1000)
792        m, s = divmod(s, 60)
793        h, m = divmod(m, 60)
794        d, h = divmod(h, 24)
795        y, d = divmod(d, 365)
796        c, y = divmod(y, 100)
797        n, c = divmod(c, 10)
798        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}'
799        spoken_time_part = []
800        if n > 0 or show_zeros_in_spoken_time:
801            spoken_time_part.append(f'{n: 3d} {millennia}')
802        if c > 0 or show_zeros_in_spoken_time:
803            spoken_time_part.append(f'{c: 4d} {century}')
804        if y > 0 or show_zeros_in_spoken_time:
805            spoken_time_part.append(f'{y: 3d} {years}')
806        if d > 0 or show_zeros_in_spoken_time:
807            spoken_time_part.append(f'{d: 4d} {days}')
808        if h > 0 or show_zeros_in_spoken_time:
809            spoken_time_part.append(f'{h: 2d} {hours}')
810        if m > 0 or show_zeros_in_spoken_time:
811            spoken_time_part.append(f'{m: 2d} {minutes}')
812        if s > 0 or show_zeros_in_spoken_time:
813            spoken_time_part.append(f'{s: 2d} {seconds}')
814        if ms > 0 or show_zeros_in_spoken_time:
815            spoken_time_part.append(f'{ms: 3d} {milli_seconds}')
816        if us > 0 or show_zeros_in_spoken_time:
817            spoken_time_part.append(f'{us: 3d} {micro_seconds}')
818        if ns > 0 or show_zeros_in_spoken_time:
819            spoken_time_part.append(f'{ns: 3d} {nano_seconds}')
820        return time_lapsed, spoken_time_separator.join(spoken_time_part)
821
822    @staticmethod
823    def test(debug: bool = False):
824        """
825        Performs unit tests to verify the correctness of the `Time` class methods.
826
827        This method checks the conversion between datetime objects and timestamps,
828        ensuring accuracy and consistency across various date ranges.
829
830        Parameters:
831        - debug (bool, optional): If True, prints the timestamp and converted datetime for each test case. Defaults to False.
832        """
833        test_cases = [
834            datetime.datetime(1, 1, 1),
835            datetime.datetime(1970, 1, 1),
836            datetime.datetime(1969, 12, 31),
837            datetime.datetime.now(),
838            datetime.datetime(9999, 12, 31, 23, 59, 59),
839        ]
840
841        for test_date in test_cases:
842            timestamp = Time.time(test_date)
843            converted = Time.time_to_datetime(timestamp)
844            if debug:
845                print(f'{timestamp} <=> {converted}')
846            assert timestamp > 0
847            assert test_date.year == converted.year
848            assert test_date.month == converted.month
849            assert test_date.day == converted.day
850            assert test_date.hour == converted.hour
851            assert test_date.minute == converted.minute
852            assert test_date.second in [converted.second - 1, converted.second, converted.second + 1]
853
854        # sanity check - convert date since 1AD to 9999AD
855
856        for year in range(1, 10_000):
857            ns = Time.time(datetime.datetime.strptime(f'{year:04d}-12-30 18:30:45', '%Y-%m-%d %H:%M:%S'))
858            date = Time.time_to_datetime(ns)
859            if debug:
860                print(date)
861            assert ns > 0
862            assert date.year == year
863            assert date.month == 12
864            assert date.day == 30
865            assert date.hour == 18
866            assert date.minute == 30
867            assert date.second in [44, 45]

Utility class for generating and manipulating nanosecond-precision timestamps.

This class provides static methods for converting between datetime objects and nanosecond-precision timestamps, ensuring uniqueness and monotonicity.

@staticmethod
def minimum_time_diff_ns() -> tuple[int, int]:
668    @staticmethod
669    def minimum_time_diff_ns() -> tuple[int, int]:
670        """
671        Calculates the minimum time difference between two consecutive calls to
672        `Time._time()` in nanoseconds.
673
674        This method is used internally to determine the minimum granularity of
675        time measurements within the system.
676
677        Returns:
678        - tuple[int, int]:
679            - The minimum time difference in nanoseconds.
680            - The number of iterations required to measure the difference.
681        """
682        i = 0
683        x = y = Time._time()
684        while x == y:
685            y = Time._time()
686            i += 1
687        return y - x, i

Calculates the minimum time difference between two consecutive calls to Time._time() in nanoseconds.

This method is used internally to determine the minimum granularity of time measurements within the system.

Returns:

  • tuple[int, int]:
    • The minimum time difference in nanoseconds.
    • The number of iterations required to measure the difference.
@staticmethod
def time(now: Optional[datetime.datetime] = None) -> Timestamp:
711    @staticmethod
712    def time(now: Optional[datetime.datetime] = None) -> Timestamp:
713        """
714        Generates a unique, monotonically increasing timestamp based on the provided
715        datetime object or the current datetime.
716
717        This method ensures that timestamps are unique even if called in rapid succession
718        by introducing a small delay if necessary, based on the system's minimum
719        time resolution.
720
721        Parameters:
722        - now (datetime.datetime, optional): The datetime object to generate the timestamp from.
723        If not provided, the current datetime is used.
724
725        Returns:
726        - Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD).
727        """
728        new_time = Time._time(now)
729        if Time.__last_time_ns is None:
730            Time.__last_time_ns = new_time
731            return new_time
732        while new_time == Time.__last_time_ns:
733            if Time.__time_diff_ns is None:
734                diff, _ = Time.minimum_time_diff_ns()
735                Time.__time_diff_ns = math.ceil(diff)
736            time.sleep(Time.__time_diff_ns / 1_000_000_000)
737            new_time = Time._time()
738        Time.__last_time_ns = new_time
739        return new_time

Generates a unique, monotonically increasing timestamp based on the provided datetime object or the current datetime.

This method ensures that timestamps are unique even if called in rapid succession by introducing a small delay if necessary, based on the system's minimum time resolution.

Parameters:

  • now (datetime.datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used.

Returns:

  • Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD).
@staticmethod
def time_to_datetime(ordinal_ns: Timestamp) -> datetime.datetime:
741    @staticmethod
742    def time_to_datetime(ordinal_ns: Timestamp) -> datetime.datetime:
743        """
744        Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD)
745        back to a datetime object.
746
747        Parameters:
748        - ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD).
749
750        Returns:
751        - datetime.datetime: The corresponding datetime object.
752        """
753        d = datetime.datetime.fromordinal(ordinal_ns // 86_400_000_000_000)
754        t = datetime.timedelta(seconds=(ordinal_ns % 86_400_000_000_000) // 10 ** 9)
755        return datetime.datetime.combine(d, datetime.time()) + t

Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD) back to a datetime object.

Parameters:

  • ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD).

Returns:

  • datetime.datetime: The corresponding datetime object.
@staticmethod
def duration_from_nanoseconds( ns: int, show_zeros_in_spoken_time: bool = False, spoken_time_separator=',', millennia: str = 'Millennia', century: str = 'Century', years: str = 'Years', days: str = 'Days', hours: str = 'Hours', minutes: str = 'Minutes', seconds: str = 'Seconds', milli_seconds: str = 'MilliSeconds', micro_seconds: str = 'MicroSeconds', nano_seconds: str = 'NanoSeconds') -> tuple:
757    @staticmethod
758    def duration_from_nanoseconds(ns: int,
759                                  show_zeros_in_spoken_time: bool = False,
760                                  spoken_time_separator=',',
761                                  millennia: str = 'Millennia',
762                                  century: str = 'Century',
763                                  years: str = 'Years',
764                                  days: str = 'Days',
765                                  hours: str = 'Hours',
766                                  minutes: str = 'Minutes',
767                                  seconds: str = 'Seconds',
768                                  milli_seconds: str = 'MilliSeconds',
769                                  micro_seconds: str = 'MicroSeconds',
770                                  nano_seconds: str = 'NanoSeconds',
771                                  ) -> tuple:
772        """
773        REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
774        Convert NanoSeconds to Human Readable Time Format.
775        A NanoSeconds is a unit of time in the International System of Units (SI) equal
776        to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
777        Its symbol is μs, sometimes simplified to us when Unicode is not available.
778        A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
779
780        INPUT : ms (AKA: MilliSeconds)
781        OUTPUT: tuple(string time_lapsed, string spoken_time) like format.
782        OUTPUT Variables: time_lapsed, spoken_time
783
784        Example  Input: duration_from_nanoseconds(ns)
785        **'Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds'**
786        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')
787        duration_from_nanoseconds(1234567890123456789012)
788        """
789        us, ns = divmod(ns, 1000)
790        ms, us = divmod(us, 1000)
791        s, ms = divmod(ms, 1000)
792        m, s = divmod(s, 60)
793        h, m = divmod(m, 60)
794        d, h = divmod(h, 24)
795        y, d = divmod(d, 365)
796        c, y = divmod(y, 100)
797        n, c = divmod(c, 10)
798        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}'
799        spoken_time_part = []
800        if n > 0 or show_zeros_in_spoken_time:
801            spoken_time_part.append(f'{n: 3d} {millennia}')
802        if c > 0 or show_zeros_in_spoken_time:
803            spoken_time_part.append(f'{c: 4d} {century}')
804        if y > 0 or show_zeros_in_spoken_time:
805            spoken_time_part.append(f'{y: 3d} {years}')
806        if d > 0 or show_zeros_in_spoken_time:
807            spoken_time_part.append(f'{d: 4d} {days}')
808        if h > 0 or show_zeros_in_spoken_time:
809            spoken_time_part.append(f'{h: 2d} {hours}')
810        if m > 0 or show_zeros_in_spoken_time:
811            spoken_time_part.append(f'{m: 2d} {minutes}')
812        if s > 0 or show_zeros_in_spoken_time:
813            spoken_time_part.append(f'{s: 2d} {seconds}')
814        if ms > 0 or show_zeros_in_spoken_time:
815            spoken_time_part.append(f'{ms: 3d} {milli_seconds}')
816        if us > 0 or show_zeros_in_spoken_time:
817            spoken_time_part.append(f'{us: 3d} {micro_seconds}')
818        if ns > 0 or show_zeros_in_spoken_time:
819            spoken_time_part.append(f'{ns: 3d} {nano_seconds}')
820        return time_lapsed, spoken_time_separator.join(spoken_time_part)

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)

@staticmethod
def test(debug: bool = False):
822    @staticmethod
823    def test(debug: bool = False):
824        """
825        Performs unit tests to verify the correctness of the `Time` class methods.
826
827        This method checks the conversion between datetime objects and timestamps,
828        ensuring accuracy and consistency across various date ranges.
829
830        Parameters:
831        - debug (bool, optional): If True, prints the timestamp and converted datetime for each test case. Defaults to False.
832        """
833        test_cases = [
834            datetime.datetime(1, 1, 1),
835            datetime.datetime(1970, 1, 1),
836            datetime.datetime(1969, 12, 31),
837            datetime.datetime.now(),
838            datetime.datetime(9999, 12, 31, 23, 59, 59),
839        ]
840
841        for test_date in test_cases:
842            timestamp = Time.time(test_date)
843            converted = Time.time_to_datetime(timestamp)
844            if debug:
845                print(f'{timestamp} <=> {converted}')
846            assert timestamp > 0
847            assert test_date.year == converted.year
848            assert test_date.month == converted.month
849            assert test_date.day == converted.day
850            assert test_date.hour == converted.hour
851            assert test_date.minute == converted.minute
852            assert test_date.second in [converted.second - 1, converted.second, converted.second + 1]
853
854        # sanity check - convert date since 1AD to 9999AD
855
856        for year in range(1, 10_000):
857            ns = Time.time(datetime.datetime.strptime(f'{year:04d}-12-30 18:30:45', '%Y-%m-%d %H:%M:%S'))
858            date = Time.time_to_datetime(ns)
859            if debug:
860                print(date)
861            assert ns > 0
862            assert date.year == year
863            assert date.month == 12
864            assert date.day == 30
865            assert date.hour == 18
866            assert date.minute == 30
867            assert date.second in [44, 45]

Performs unit tests to verify the correctness of the Time class methods.

This method checks the conversion between datetime objects and timestamps, ensuring accuracy and consistency across various date ranges.

Parameters:

  • debug (bool, optional): If True, prints the timestamp and converted datetime for each test case. Defaults to False.
@staticmethod
def time(now: Optional[datetime.datetime] = None) -> Timestamp:
711    @staticmethod
712    def time(now: Optional[datetime.datetime] = None) -> Timestamp:
713        """
714        Generates a unique, monotonically increasing timestamp based on the provided
715        datetime object or the current datetime.
716
717        This method ensures that timestamps are unique even if called in rapid succession
718        by introducing a small delay if necessary, based on the system's minimum
719        time resolution.
720
721        Parameters:
722        - now (datetime.datetime, optional): The datetime object to generate the timestamp from.
723        If not provided, the current datetime is used.
724
725        Returns:
726        - Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD).
727        """
728        new_time = Time._time(now)
729        if Time.__last_time_ns is None:
730            Time.__last_time_ns = new_time
731            return new_time
732        while new_time == Time.__last_time_ns:
733            if Time.__time_diff_ns is None:
734                diff, _ = Time.minimum_time_diff_ns()
735                Time.__time_diff_ns = math.ceil(diff)
736            time.sleep(Time.__time_diff_ns / 1_000_000_000)
737            new_time = Time._time()
738        Time.__last_time_ns = new_time
739        return new_time

Generates a unique, monotonically increasing timestamp based on the provided datetime object or the current datetime.

This method ensures that timestamps are unique even if called in rapid succession by introducing a small delay if necessary, based on the system's minimum time resolution.

Parameters:

  • now (datetime.datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used.

Returns:

  • Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD).
@staticmethod
def time_to_datetime(ordinal_ns: Timestamp) -> datetime.datetime:
741    @staticmethod
742    def time_to_datetime(ordinal_ns: Timestamp) -> datetime.datetime:
743        """
744        Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD)
745        back to a datetime object.
746
747        Parameters:
748        - ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD).
749
750        Returns:
751        - datetime.datetime: The corresponding datetime object.
752        """
753        d = datetime.datetime.fromordinal(ordinal_ns // 86_400_000_000_000)
754        t = datetime.timedelta(seconds=(ordinal_ns % 86_400_000_000_000) // 10 ** 9)
755        return datetime.datetime.combine(d, datetime.time()) + t

Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD) back to a datetime object.

Parameters:

  • ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD).

Returns:

  • datetime.datetime: The corresponding datetime object.
tracker = <class 'ZakatTracker'>
class ZakatTracker:
 870class ZakatTracker:
 871    """
 872    A class for tracking and calculating Zakat.
 873
 874    This class provides functionalities for recording transactions, calculating Zakat due,
 875    and managing account balances. It also offers features like importing transactions from
 876    CSV files, exporting data to JSON format, and saving/loading the tracker state.
 877
 878    The `ZakatTracker` class is designed to handle both positive and negative transactions,
 879    allowing for flexible tracking of financial activities related to Zakat. It also supports
 880    the concept of a 'Nisab' (minimum threshold for Zakat) and a 'haul' (complete one year for Transaction) can calculate Zakat due
 881    based on the current silver price.
 882
 883    The class uses a json file as its database to persist the tracker state,
 884    ensuring data integrity across sessions. It also provides options for enabling or
 885    disabling history tracking, allowing users to choose their preferred level of detail.
 886
 887    In addition, the `ZakatTracker` class includes various helper methods like
 888    `time`, `time_to_datetime`, `lock`, `free`, `recall`, `save`, `load`
 889    and more. These methods provide additional functionalities and flexibility
 890    for interacting with and managing the Zakat tracker.
 891
 892    Attributes:
 893    - ZakatTracker.ZakatCut (function): A function to calculate the Zakat percentage.
 894    - ZakatTracker.TimeCycle (function): A function to determine the time cycle for Zakat.
 895    - ZakatTracker.Nisab (function): A function to calculate the Nisab based on the silver price.
 896    - ZakatTracker.Version (function): The version of the ZakatTracker class.
 897
 898    Data Structure:
 899    
 900    The ZakatTracker class utilizes a nested dataclasses structure called '__vault' to store and manage data.
 901
 902        __vault (dict):
 903            - account (dict):
 904                - {account_name} (dict):
 905                    - balance (int): The current balance of the account.
 906                    - box (dict): A dictionary storing transaction details.
 907                        - {timestamp} (dict):
 908                            - capital (int): The initial amount of the transaction.
 909                            - count (int): The number of times Zakat has been calculated for this transaction.
 910                            - last (int): The timestamp of the last Zakat calculation.
 911                            - rest (int): The remaining amount after Zakat deductions and withdrawal.
 912                            - total (int): The total Zakat deducted from this transaction.
 913                    - count (int): The total number of transactions for the account.
 914                    - log (dict): A dictionary storing transaction logs.
 915                        - {timestamp} (dict):
 916                            - value (int): The transaction amount (positive or negative).
 917                            - desc (str): The description of the transaction.
 918                            - ref (int): The box reference (positive or None).
 919                            - file (dict): A dictionary storing file references associated with the transaction.
 920                    - hide (bool): Indicates whether the account is hidden or not.
 921                    - zakatable (bool): Indicates whether the account is subject to Zakat.
 922            - exchange (dict):
 923                - {account_name} (dict):
 924                    - {timestamps} (dict):
 925                        - rate (float): Exchange rate when compared to local currency.
 926                        - description (str): The description of the exchange rate.
 927            - history (dict):
 928                - {timestamp} (list): A list of dictionaries storing the history of actions performed.
 929                    - {action_dict} (dict):
 930                        - action (Action): The type of action (CREATE, TRACK, LOG, SUB, ADD_FILE, REMOVE_FILE, BOX_TRANSFER, EXCHANGE, REPORT, ZAKAT).
 931                        - account (str): The account number associated with the action.
 932                        - ref (int): The reference number of the transaction.
 933                        - file (int): The reference number of the file (if applicable).
 934                        - key (str): The key associated with the action (e.g., 'rest', 'total').
 935                        - value (int): The value associated with the action.
 936                        - math (MathOperation): The mathematical operation performed (if applicable).
 937            - lock (int or None): The timestamp indicating the current lock status (None if not locked).
 938            - report (dict):
 939                - {timestamp} (tuple): A tuple storing Zakat report details.
 940
 941    """
 942
 943    @staticmethod
 944    def Version() -> str:
 945        """
 946        Returns the current version of the software.
 947
 948        This function returns a string representing the current version of the software,
 949        including major, minor, and patch version numbers in the format 'X.Y.Z'.
 950
 951        Returns:
 952        - str: The current version of the software.
 953        """
 954        version = '0.3.1'
 955        git_hash, unstaged_count, commit_count_since_last_tag = get_git_status()
 956        if git_hash and (unstaged_count > 0 or commit_count_since_last_tag > 0):
 957            version += f".{commit_count_since_last_tag}dev{unstaged_count}+{git_hash}"
 958            print(version)
 959        return version
 960
 961    @staticmethod
 962    def ZakatCut(x: float) -> float:
 963        """
 964        Calculates the Zakat amount due on an asset.
 965
 966        This function calculates the zakat amount due on a given asset value over one lunar year.
 967        Zakat is an Islamic obligatory alms-giving, calculated as a fixed percentage of an individual's wealth
 968        that exceeds a certain threshold (Nisab).
 969
 970        Parameters:
 971        - x (float): The total value of the asset on which Zakat is to be calculated.
 972
 973        Returns:
 974        - float: The amount of Zakat due on the asset, calculated as 2.5% of the asset's value.
 975        """
 976        return 0.025 * x  # Zakat Cut in one Lunar Year
 977
 978    @staticmethod
 979    def TimeCycle(days: int = 355) -> int:
 980        """
 981        Calculates the approximate duration of a lunar year in nanoseconds.
 982
 983        This function calculates the approximate duration of a lunar year based on the given number of days.
 984        It converts the given number of days into nanoseconds for use in high-precision timing applications.
 985
 986        Parameters:
 987        - days (int, optional): The number of days in a lunar year. Defaults to 355,
 988              which is an approximation of the average length of a lunar year.
 989
 990        Returns:
 991        - int: The approximate duration of a lunar year in nanoseconds.
 992        """
 993        return int(60 * 60 * 24 * days * 1e9)  # Lunar Year in nanoseconds
 994
 995    @staticmethod
 996    def Nisab(gram_price: float, gram_quantity: float = 595) -> float:
 997        """
 998        Calculate the total value of Nisab (a unit of weight in Islamic jurisprudence) based on the given price per gram.
 999
1000        This function calculates the Nisab value, which is the minimum threshold of wealth,
1001        that makes an individual liable for paying Zakat.
1002        The Nisab value is determined by the equivalent value of a specific amount
1003        of gold or silver (currently 595 grams in silver) in the local currency.
1004
1005        Parameters:
1006        - gram_price (float): The price per gram of Nisab.
1007        - gram_quantity (float, optional): The quantity of grams in a Nisab. Default is 595 grams of silver.
1008
1009        Returns:
1010        - float: The total value of Nisab based on the given price per gram.
1011        """
1012        return gram_price * gram_quantity
1013
1014    @staticmethod
1015    def ext() -> str:
1016        """
1017        Returns the file extension used by the ZakatTracker class.
1018
1019        Parameters:
1020        None
1021
1022        Returns:
1023        - str: The file extension used by the ZakatTracker class, which is 'json'.
1024        """
1025        return 'json'
1026
1027    __base_path = ""
1028    __vault_path = ""
1029    __memory_mode = False
1030    __vault: Vault
1031
1032    def __init__(self, db_path: str = './zakat_db/', history_mode: bool = True):
1033        """
1034        Initialize ZakatTracker with database path and history mode.
1035
1036        Parameters:
1037        - db_path (str, optional): The path to the database  directory. Defaults to './zakat_db/'. Use ':memory:' for an in-memory database.
1038        - history_mode (bool, optional): The mode for tracking history. Default is True.
1039
1040        Returns:
1041        None
1042        """
1043        self.reset()
1044        self.__memory_mode = db_path == ':memory:'
1045        self.__history(history_mode)
1046        if not self.__memory_mode:
1047            self.path(f'{db_path}/db.{self.ext()}')
1048
1049    def memory_mode(self) -> bool:
1050        """
1051        Check if the ZakatTracker is operating in memory mode.
1052
1053        Returns:
1054        - bool: True if the database is in memory, False otherwise.
1055        """
1056        return self.__memory_mode
1057
1058    def path(self, path: Optional[str] = None) -> str:
1059        """
1060        Set or get the path to the database file.
1061
1062        If no path is provided, the current path is returned.
1063        If a path is provided, it is set as the new path.
1064        The function also creates the necessary directories if the provided path is a file.
1065
1066        Parameters:
1067        - path (str, optional): The new path to the database file. If not provided, the current path is returned.
1068
1069        Returns:
1070        - str: The current or new path to the database file.
1071        """
1072        if path is None:
1073            return self.__vault_path
1074        self.__vault_path = pathlib.Path(path).resolve()
1075        base_path = pathlib.Path(path).resolve()
1076        if base_path.is_file() or base_path.suffix:
1077            base_path = base_path.parent
1078        base_path.mkdir(parents=True, exist_ok=True)
1079        self.__base_path = base_path
1080        return str(self.__vault_path)
1081
1082    def base_path(self, *args) -> str:
1083        """
1084        Generate a base path by joining the provided arguments with the existing base path.
1085
1086        Parameters:
1087        - *args (str): Variable length argument list of strings to be joined with the base path.
1088
1089        Returns:
1090        - str: The generated base path. If no arguments are provided, the existing base path is returned.
1091        """
1092        if not args:
1093            return str(self.__base_path)
1094        filtered_args = []
1095        ignored_filename = None
1096        for arg in args:
1097            if pathlib.Path(arg).suffix:
1098                ignored_filename = arg
1099            else:
1100                filtered_args.append(arg)
1101        base_path = pathlib.Path(self.__base_path)
1102        full_path = base_path.joinpath(*filtered_args)
1103        full_path.mkdir(parents=True, exist_ok=True)
1104        if ignored_filename is not None:
1105            return full_path.resolve() / ignored_filename  # Join with the ignored filename
1106        return str(full_path.resolve())
1107
1108    @staticmethod
1109    def scale(x: float | int | decimal.Decimal, decimal_places: int = 2) -> int:
1110        """
1111        Scales a numerical value by a specified power of 10, returning an integer.
1112
1113        This function is designed to handle various numeric types (`float`, `int`, or `decimal.Decimal`) and
1114        facilitate precise scaling operations, particularly useful in financial or scientific calculations.
1115
1116        Parameters:
1117        - x (float | int | decimal.Decimal): The numeric value to scale. Can be a floating-point number, integer, or decimal.
1118        - decimal_places (int, optional): The exponent for the scaling factor (10**y). Defaults to 2, meaning the input is scaled
1119            by a factor of 100 (e.g., converts 1.23 to 123).
1120
1121        Returns:
1122        - The scaled value, rounded to the nearest integer.
1123
1124        Raises:
1125        - TypeError: If the input `x` is not a valid numeric type.
1126
1127        Examples:
1128        ```bash
1129        >>> ZakatTracker.scale(3.14159)
1130        314
1131        >>> ZakatTracker.scale(1234, decimal_places=3)
1132        1234000
1133        >>> ZakatTracker.scale(decimal.Decimal('0.005'), decimal_places=4)
1134        50
1135        ```
1136        """
1137        if not isinstance(x, (float, int, decimal.Decimal)):
1138            raise TypeError(f'Input "{x}" must be a float, int, or decimal.Decimal.')
1139        return int(decimal.Decimal(f'{x:.{decimal_places}f}') * (10 ** decimal_places))
1140
1141    @staticmethod
1142    def unscale(x: int, return_type: type = float, decimal_places: int = 2) -> float | decimal.Decimal:
1143        """
1144        Unscales an integer by a power of 10.
1145
1146        Parameters:
1147        - x (int): The integer to unscale.
1148        - return_type (type, optional): The desired type for the returned value. Can be float, int, or decimal.Decimal. Defaults to float.
1149        - decimal_places (int, optional): The power of 10 to use. Defaults to 2.
1150
1151        Returns:
1152        - float | int | decimal.Decimal: The unscaled number, converted to the specified return_type.
1153
1154        Raises:
1155        - TypeError: If the return_type is not float or decimal.Decimal.
1156        """
1157        if return_type not in (float, decimal.Decimal):
1158            raise TypeError(f'Invalid return_type({return_type}). Supported types are float, int, and decimal.Decimal.')
1159        return round(return_type(x / (10 ** decimal_places)), decimal_places)
1160
1161    def reset(self) -> None:
1162        """
1163        Reset the internal data structure to its initial state.
1164
1165        Parameters:
1166        None
1167
1168        Returns:
1169        None
1170        """
1171        self.__vault = Vault()
1172
1173    def clean_history(self, lock: Optional[Timestamp] = None) -> int:
1174        """
1175        Cleans up the empty history records of actions performed on the ZakatTracker instance.
1176
1177        Parameters:
1178        - lock (Timestamp, optional): The lock ID is used to clean up the empty history.
1179            If not provided, it cleans up the empty history records for all locks.
1180
1181        Returns:
1182        - int: The number of locks cleaned up.
1183        """
1184        count = 0
1185        if lock in self.__vault.history:
1186            if len(self.__vault.history[lock]) <= 0:
1187                count += 1
1188                del self.__vault.history[lock]
1189            return count
1190        for key in self.__vault.history:
1191            if len(self.__vault.history[key]) <= 0:
1192                count += 1
1193                del self.__vault.history[key]
1194        return count
1195
1196    def __history(self, status: Optional[bool] = None) -> bool:
1197        """
1198        Enable or disable history tracking.
1199
1200        Parameters:
1201        - status (bool, optional): The status of history tracking. Default is True.
1202
1203        Returns:
1204        None
1205        """
1206        if status is not None:
1207            self.__history_mode = status
1208        return self.__history_mode
1209
1210    def __step(self, action: Optional[Action] = None,
1211                    account: Optional[AccountName] = None,
1212                    ref: Optional[Timestamp] = None,
1213                    file: Optional[Timestamp] = None,
1214                    value: Optional[any] = None, # !!!
1215                    key: Optional[str] = None,
1216                    math_operation: Optional[MathOperation] = None,
1217                    lock_once: bool = True,
1218                    debug: bool = False,
1219                ) -> Optional[Timestamp]:
1220        """
1221        This method is responsible for recording the actions performed on the ZakatTracker.
1222
1223        Parameters:
1224        - action (Action, optional): The type of action performed.
1225        - account (AccountName, optional): The account number on which the action was performed.
1226        - ref (Optional, optional): The reference number of the action.
1227        - file (Timestamp, optional): The file reference number of the action.
1228        - value (any, optional): The value associated with the action.
1229        - key (str, optional): The key associated with the action.
1230        - math_operation (MathOperation, optional): The mathematical operation performed during the action.
1231        - lock_once (bool, optional): Indicates whether a lock should be acquired only once. Defaults to True.
1232        - debug (bool, optional): If True, the function will print debug information. Default is False.
1233
1234        Returns:
1235        - Optional[Timestamp]: The lock time of the recorded action. If no lock was performed, it returns 0.
1236        """
1237        if not self.__history():
1238            return None
1239        no_lock = self.nolock()
1240        lock = self.__vault.lock
1241        if no_lock:
1242            lock = self.__vault.lock = Time.time()
1243            self.__vault.history[lock] = []
1244        if action is None:
1245            if lock_once:
1246                assert no_lock, 'forbidden: lock called twice!!!'
1247            return lock
1248        if debug:
1249             print_stack()
1250        assert lock is not None
1251        assert lock > 0
1252        assert account is None or action != Action.REPORT
1253        self.__vault.history[lock].append(History(
1254            action=action,
1255            account=account,
1256            ref=ref,
1257            file=file,
1258            key=key,
1259            value=value,
1260            math=math_operation,
1261        ))
1262        return lock
1263
1264    def nolock(self) -> bool:
1265        """
1266        Check if the vault lock is currently not set.
1267
1268        Parameters:
1269        None
1270
1271        Returns:
1272        - bool: True if the vault lock is not set, False otherwise.
1273        """
1274        return self.__vault.lock is None
1275
1276    def __lock(self) -> Optional[Timestamp]:
1277        """
1278        Acquires a lock, potentially repeatedly, by calling the internal `_step` method.
1279
1280        This method specifically invokes the `_step` method with `lock_once` set to `False`
1281        indicating that the lock should be acquired even if it was previously acquired.
1282        This is useful for ensuring a lock is held throughout a critical section of code
1283
1284        Returns:
1285        - Optional[Timestamp]: The status code or result returned by the `_step` method, indicating theoutcome of the lock acquisition attempt.
1286        """
1287        return self.__step(lock_once=False)
1288
1289    def lock(self) -> Optional[Timestamp]:
1290        """
1291        Acquires a lock on the ZakatTracker instance.
1292
1293        Parameters:
1294        None
1295
1296        Returns:
1297        - Optional[Timestamp]: The lock ID. This ID can be used to release the lock later.
1298        """
1299        return self.__step()
1300
1301    def steps(self) -> dict:
1302        """
1303        Returns a copy of the history of steps taken in the ZakatTracker.
1304
1305        The history is a dictionary where each key is a unique identifier for a step,
1306        and the corresponding value is a dictionary containing information about the step.
1307
1308        Parameters:
1309        None
1310
1311        Returns:
1312        - dict: A copy of the history of steps taken in the ZakatTracker.
1313        """
1314        return self.__vault.history.copy()
1315
1316    def free(self, lock: Timestamp, auto_save: bool = True) -> bool:
1317        """
1318        Releases the lock on the database.
1319
1320        Parameters:
1321        - lock (Timestamp): The lock ID to be released.
1322        - auto_save (bool, optional): Whether to automatically save the database after releasing the lock.
1323
1324        Returns:
1325        - bool: True if the lock is successfully released and (optionally) saved, False otherwise.
1326        """
1327        if lock == self.__vault.lock:
1328            self.clean_history(lock)
1329            self.__vault.lock = None
1330            if auto_save and not self.memory_mode():
1331                return self.save(self.path())
1332            return True
1333        return False
1334
1335    def recall(self, dry: bool = True, lock: Optional[Timestamp] = None, debug: bool = False) -> bool:
1336        """
1337        Revert the last operation.
1338
1339        Parameters:
1340        - dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
1341        - lock (Timestamp, optional): An optional lock value to ensure the recall
1342                operation is performed on the expected history entry. If provided,
1343                it checks if the current lock and the most recent history key
1344                match the given lock value. Defaults to None.
1345        - debug (bool, optional): If True, the function will print debug information. Default is False.
1346
1347        Returns:
1348        - bool: True if the operation was successful, False otherwise.
1349        """
1350        if not self.nolock() or len(self.__vault.history) == 0:
1351            return False
1352        if len(self.__vault.history) <= 0:
1353            return False
1354        ref = sorted(self.__vault.history.keys())[-1]
1355        if debug:
1356            print('recall', ref)
1357        memory = self.__vault.history[ref]
1358        if debug:
1359            print(type(memory), 'memory', memory)
1360        if lock is not None:
1361            assert self.__vault.lock == lock, "Invalid current lock"
1362            assert ref == lock, "Invalid last lock"
1363            assert self.__history(), "History mode should be enabled, found off!!!"
1364        limit = len(memory) + 1
1365        sub_positive_log_negative = 0
1366        for i in range(-1, -limit, -1):
1367            x = memory[i]
1368            if debug:
1369                print(type(x), x)
1370            match x.action:
1371                case Action.CREATE:
1372                    if x.account is not None:
1373                        if self.account_exists(x.account):
1374                            if debug:
1375                                print('account', self.__vault.account[x.account])
1376                            assert len(self.__vault.account[x.account].box) == 0
1377                            assert len(self.__vault.account[x.account].log) == 0
1378                            assert self.__vault.account[x.account].balance == 0
1379                            assert self.__vault.account[x.account].count == 0
1380                            if dry:
1381                                continue
1382                            del self.__vault.account[x.account]
1383
1384                case Action.TRACK:
1385                    if x.account is not None:
1386                        if self.account_exists(x.account):
1387                            if dry:
1388                                continue
1389                            assert x.value is not None
1390                            assert x.ref is not None
1391                            self.__vault.account[x.account].balance -= x.value
1392                            self.__vault.account[x.account].count -= 1
1393                            del self.__vault.account[x.account].box[x.ref]
1394
1395                case Action.LOG:
1396                    if x.account is not None:
1397                        if self.account_exists(x.account):
1398                            if x.ref in self.__vault.account[x.account].log:
1399                                if dry:
1400                                    continue
1401                                assert x.value is not None
1402                                if sub_positive_log_negative == -x.value:
1403                                    self.__vault.account[x.account].count -= 1
1404                                    sub_positive_log_negative = 0
1405                                box_ref = self.__vault.account[x.account].log[x.ref].ref
1406                                if not box_ref is None:
1407                                    assert self.box_exists(x.account, box_ref)
1408                                    box_value = self.__vault.account[x.account].log[x.ref].value
1409                                    assert box_value < 0
1410
1411                                    try:
1412                                        self.__vault.account[x.account].box[box_ref].rest += -box_value
1413                                    except TypeError:
1414                                        self.__vault.account[x.account].box[box_ref].rest += decimal.Decimal(-box_value)
1415
1416                                    try:
1417                                        self.__vault.account[x.account].balance += -box_value
1418                                    except TypeError:
1419                                        self.__vault.account[x.account].balance += decimal.Decimal(-box_value)
1420
1421                                    self.__vault.account[x.account].count -= 1
1422                                del self.__vault.account[x.account].log[x.ref]
1423
1424                case Action.SUBTRACT:
1425                    if x.account is not None:
1426                        if self.account_exists(x.account):
1427                            if x.ref in self.__vault.account[x.account].box:
1428                                if dry:
1429                                    continue
1430                                assert x.value is not None
1431                                self.__vault.account[x.account].box[x.ref].rest += x.value
1432                                self.__vault.account[x.account].balance += x.value
1433                                sub_positive_log_negative = x.value
1434
1435                case Action.ADD_FILE:
1436                    if x.account is not None:
1437                        if self.account_exists(x.account):
1438                            if x.ref in self.__vault.account[x.account].log:
1439                                if x.file in self.__vault.account[x.account].log[x.ref].file:
1440                                    if dry:
1441                                        continue
1442                                    del self.__vault.account[x.account].log[x.ref].file[x.file]
1443
1444                case Action.REMOVE_FILE:
1445                    if x.account is not None:
1446                        if self.account_exists(x.account):
1447                            if x.ref in self.__vault.account[x.account].log:
1448                                if dry:
1449                                    continue
1450                                assert x.file is not None
1451                                assert x.value is not None
1452                                self.__vault.account[x.account].log[x.ref].file[x.file] = x.value
1453
1454                case Action.BOX_TRANSFER:
1455                    if x.account is not None:
1456                        if self.account_exists(x.account):
1457                            if x.ref in self.__vault.account[x.account].box:
1458                                if dry:
1459                                    continue
1460                                assert x.value is not None
1461                                self.__vault.account[x.account].box[x.ref].rest -= x.value
1462
1463                case Action.EXCHANGE:
1464                    if x.account is not None:
1465                        if x.account in self.__vault.exchange:
1466                            if x.ref in self.__vault.exchange[x.account]:
1467                                if dry:
1468                                    continue
1469                                del self.__vault.exchange[x.account][x.ref]
1470
1471                case Action.REPORT:
1472                    if x.ref in self.__vault.report:
1473                        if dry:
1474                            continue
1475                        del self.__vault.report[x.ref]
1476
1477                case Action.ZAKAT:
1478                    if x.account is not None:
1479                        if self.account_exists(x.account):
1480                            if x.ref in self.__vault.account[x.account].box:
1481                                assert x.key is not None
1482                                if hasattr(self.__vault.account[x.account].box[x.ref], x.key):
1483                                    if dry:
1484                                        continue
1485                                    match x.math:
1486                                        case MathOperation.ADDITION:
1487                                            setattr(
1488                                                self.__vault.account[x.account].box[x.ref],
1489                                                x.key,
1490                                                getattr(self.__vault.account[x.account].box[x.ref], x.key) - x.value,
1491                                            )
1492                                        case MathOperation.EQUAL:
1493                                            setattr(
1494                                                self.__vault.account[x.account].box[x.ref],
1495                                                x.key,
1496                                                x.value,
1497                                            )
1498                                        case MathOperation.SUBTRACTION:
1499                                            setattr(
1500                                                self.__vault.account[x.account].box[x.ref],
1501                                                x.key,
1502                                                getattr(self.__vault.account[x.account].box[x.ref], x.key) + x.value,
1503                                            )
1504
1505        if not dry:
1506            del self.__vault.history[ref]
1507        return True
1508
1509    def vault(self) -> dict:
1510        """
1511        Returns a copy of the internal vault dictionary.
1512
1513        This method is used to retrieve the current state of the ZakatTracker object.
1514        It provides a snapshot of the internal data structure, allowing for further
1515        processing or analysis.
1516
1517        Parameters:
1518        None
1519
1520        Returns:
1521        - dict: A copy of the internal vault dictionary.
1522        """
1523        return dataclasses.asdict(self.__vault)
1524
1525    @staticmethod
1526    def stats_init() -> dict[str, tuple[int, str]]:
1527        """
1528        Initialize and return a dictionary containing initial statistics for the ZakatTracker instance.
1529
1530        The dictionary contains two keys: 'database' and 'ram'. Each key maps to a tuple containing two elements:
1531        - The initial size of the respective statistic in bytes (int).
1532        - The initial size of the respective statistic in a human-readable format (str).
1533
1534        Parameters:
1535        None
1536
1537        Returns:
1538        - dict[str, tuple]: A dictionary with initial statistics for the ZakatTracker instance.
1539        """
1540        return {
1541            'database': (0, '0'),
1542            'ram': (0, '0'),
1543        }
1544
1545    def stats(self, ignore_ram: bool = True) -> dict[str, tuple[float, str]]:
1546        """
1547        Calculates and returns statistics about the object's data storage.
1548
1549        This method determines the size of the database file on disk and the
1550        size of the data currently held in RAM (likely within a dictionary).
1551        Both sizes are reported in bytes and in a human-readable format
1552        (e.g., KB, MB).
1553
1554        Parameters:
1555        - ignore_ram (bool, optional): Whether to ignore the RAM size. Default is True
1556
1557        Returns:
1558        - dict[str, tuple[float, str]]: A dictionary containing the following statistics:
1559
1560            * 'database': A tuple with two elements:
1561                - The database file size in bytes (float).
1562                - The database file size in human-readable format (str).
1563            * 'ram': A tuple with two elements:
1564                - The RAM usage (dictionary size) in bytes (float).
1565                - The RAM usage in human-readable format (str).
1566
1567        Example:
1568        ```bash
1569        >>> x = ZakatTracker()
1570        >>> stats = x.stats()
1571        >>> print(stats['database'])
1572        (256000, '250.0 KB')
1573        >>> print(stats['ram'])
1574        (12345, '12.1 KB')
1575        ```
1576        """
1577        ram_size = 0.0 if ignore_ram else self.get_dict_size(self.vault())
1578        file_size = os.path.getsize(self.path())
1579        return {
1580            'database': (file_size, self.human_readable_size(file_size)),
1581            'ram': (ram_size, self.human_readable_size(ram_size)),
1582        }
1583
1584    def files(self) -> list[dict[str, str | int]]:
1585        """
1586        Retrieves information about files associated with this class.
1587
1588        This class method provides a standardized way to gather details about
1589        files used by the class for storage, snapshots, and CSV imports.
1590
1591        Parameters:
1592        None
1593
1594        Returns:
1595        - list[dict[str, str | int]]: A list of dictionaries, each containing information
1596            about a specific file:
1597
1598            * type (str): The type of file ('database', 'snapshot', 'import_csv').
1599            * path (str): The full file path.
1600            * exists (bool): Whether the file exists on the filesystem.
1601            * size (int): The file size in bytes (0 if the file doesn't exist).
1602            * human_readable_size (str): A human-friendly representation of the file size (e.g., '10 KB', '2.5 MB').
1603
1604        Example:
1605        ```
1606        file_info = MyClass.files()
1607        for info in file_info:
1608            print(f'Type: {info['type']}, Exists: {info['exists']}, Size: {info['human_readable_size']}')
1609        ```
1610        """
1611        result = []
1612        for file_type, path in {
1613            'database': self.path(),
1614            'snapshot': self.snapshot_cache_path(),
1615            'import_csv': self.import_csv_cache_path(),
1616        }.items():
1617            exists = os.path.exists(path)
1618            size = os.path.getsize(path) if exists else 0
1619            human_readable_size = self.human_readable_size(size) if exists else 0
1620            result.append({
1621                'type': file_type,
1622                'path': path,
1623                'exists': exists,
1624                'size': size,
1625                'human_readable_size': human_readable_size,
1626            })
1627        return result
1628
1629    def account_exists(self, account: AccountName) -> bool:
1630        """
1631        Check if the given account exists in the vault.
1632
1633        Parameters:
1634        - account (AccountName): The account number to check.
1635
1636        Returns:
1637        - bool: True if the account exists, False otherwise.
1638        """
1639        return account in self.__vault.account
1640
1641    def box_size(self, account: AccountName) -> int:
1642        """
1643        Calculate the size of the box for a specific account.
1644
1645        Parameters:
1646        - account (AccountName): The account number for which the box size needs to be calculated.
1647
1648        Returns:
1649        - int: The size of the box for the given account. If the account does not exist, -1 is returned.
1650        """
1651        if self.account_exists(account):
1652            return len(self.__vault.account[account].box)
1653        return -1
1654
1655    def log_size(self, account: AccountName) -> int:
1656        """
1657        Get the size of the log for a specific account.
1658
1659        Parameters:
1660        - account (AccountName): The account number for which the log size needs to be calculated.
1661
1662        Returns:
1663        - int: The size of the log for the given account. If the account does not exist, -1 is returned.
1664        """
1665        if self.account_exists(account):
1666            return len(self.__vault.account[account].log)
1667        return -1
1668
1669    @staticmethod
1670    def hash_data(data: bytes, algorithm: str = 'blake2b') -> str:
1671        """
1672        Calculates the hash of given byte data using the specified algorithm.
1673
1674        Parameters:
1675        - data (bytes): The byte data to hash.
1676        - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'.
1677
1678        Returns:
1679        - str: The hexadecimal representation of the data's hash.
1680        """
1681        hash_obj = hashlib.new(algorithm)
1682        hash_obj.update(data)
1683        return hash_obj.hexdigest()
1684
1685    @staticmethod
1686    def hash_file(file_path: str, algorithm: str = 'blake2b') -> str:
1687        """
1688        Calculates the hash of a file using the specified algorithm.
1689
1690        Parameters:
1691        - file_path (str): The path to the file.
1692        - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'.
1693
1694        Returns:
1695        - str: The hexadecimal representation of the file's hash.
1696        """
1697        hash_obj = hashlib.new(algorithm)  # Create the hash object
1698        with open(file_path, 'rb') as file:  # Open file in binary mode for reading
1699            for chunk in iter(lambda: file.read(4096), b''):  # Read file in chunks
1700                hash_obj.update(chunk)
1701        return hash_obj.hexdigest()  # Return the hash as a hexadecimal string
1702
1703    def snapshot_cache_path(self):
1704        """
1705        Generate the path for the cache file used to store snapshots.
1706
1707        The cache file is a json file that stores the timestamps of the snapshots.
1708        The file name is derived from the main database file name by replacing the '.json' extension with '.snapshots.json'.
1709
1710        Parameters:
1711        None
1712
1713        Returns:
1714        - str: The path to the cache file.
1715        """
1716        path = str(self.path())
1717        ext = self.ext()
1718        ext_len = len(ext)
1719        if path.endswith(f'.{ext}'):
1720            path = path[:-ext_len - 1]
1721        _, filename = os.path.split(path + f'.snapshots.{ext}')
1722        return self.base_path(filename)
1723
1724    def snapshot(self) -> bool:
1725        """
1726        This function creates a snapshot of the current database state.
1727
1728        The function calculates the hash of the current database file and checks if a snapshot with the same hash already exists.
1729        If a snapshot with the same hash exists, the function returns True without creating a new snapshot.
1730        If a snapshot with the same hash does not exist, the function creates a new snapshot by saving the current database state
1731        in a new json file with a unique timestamp as the file name. The function also updates the snapshot cache file with the new snapshot's hash and timestamp.
1732
1733        Parameters:
1734        None
1735
1736        Returns:
1737        - bool: True if a snapshot with the same hash already exists or if the snapshot is successfully created. False if the snapshot creation fails.
1738        """
1739        current_hash = self.hash_file(self.path())
1740        cache: dict[str, int] = {}  # hash: time_ns
1741        try:
1742            with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream:
1743                cache = json.load(stream, cls=JSONDecoder)
1744        except:
1745            pass
1746        if current_hash in cache:
1747            return True
1748        ref = time.time_ns()
1749        cache[current_hash] = ref
1750        if not self.save(self.base_path('snapshots', f'{ref}.{self.ext()}')):
1751            return False
1752        with open(self.snapshot_cache_path(), 'w', encoding='utf-8') as stream:
1753            stream.write(json.dumps(cache, cls=JSONEncoder))
1754        return True
1755
1756    def snapshots(self, hide_missing: bool = True, verified_hash_only: bool = False) \
1757            -> dict[int, tuple[str, str, bool]]:
1758        """
1759        Retrieve a dictionary of snapshots, with their respective hashes, paths, and existence status.
1760
1761        Parameters:
1762        - hide_missing (bool, optional): If True, only include snapshots that exist in the dictionary. Default is True.
1763        - verified_hash_only (bool, optional): If True, only include snapshots with a valid hash. Default is False.
1764
1765        Returns:
1766        - dict[int, tuple[str, str, bool]]: A dictionary where the keys are the timestamps of the snapshots,
1767        and the values are tuples containing the snapshot's hash, path, and existence status.
1768        """
1769        cache: dict[str, int] = {}  # hash: time_ns
1770        try:
1771            with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream:
1772                cache = json.load(stream, cls=JSONDecoder)
1773        except:
1774            pass
1775        if not cache:
1776            return {}
1777        result: dict[int, tuple[str, str, bool]] = {}  # time_ns: (hash, path, exists)
1778        for hash_file, ref in cache.items():
1779            path = self.base_path('snapshots', f'{ref}.{self.ext()}')
1780            exists = os.path.exists(path)
1781            valid_hash = self.hash_file(path) == hash_file if verified_hash_only else True
1782            if (verified_hash_only and not valid_hash) or (verified_hash_only and not exists):
1783                continue
1784            if exists or not hide_missing:
1785                result[ref] = (hash_file, path, exists)
1786        return result
1787
1788    def ref_exists(self, account: AccountName, ref_type: str, ref: Timestamp) -> bool:
1789        """
1790        Check if a specific reference (transaction) exists in the vault for a given account and reference type.
1791
1792        Parameters:
1793        - account (AccountName): The account number for which to check the existence of the reference.
1794        - ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
1795        - ref (Timestamp): The reference (transaction) number to check for existence.
1796
1797        Returns:
1798        - bool: True if the reference exists for the given account and reference type, False otherwise.
1799        """
1800        if account in self.__vault.account:
1801            return ref in getattr(self.__vault.account[account], ref_type)
1802        return False
1803
1804    def box_exists(self, account: AccountName, ref: Timestamp) -> bool:
1805        """
1806        Check if a specific box (transaction) exists in the vault for a given account and reference.
1807
1808        Parameters:
1809        - account (AccountName): The account number for which to check the existence of the box.
1810        - ref (Timestamp): The reference (transaction) number to check for existence.
1811
1812        Returns:
1813        - bool: True if the box exists for the given account and reference, False otherwise.
1814        """
1815        return self.ref_exists(account, 'box', ref)
1816
1817    def track(self, unscaled_value: float | int | decimal.Decimal = 0, desc: str = '', account: AccountName = AccountName('1'),
1818              created_time_ns: Optional[Timestamp] = None,
1819              debug: bool = False) -> Timestamp:
1820        """
1821        This function tracks a transaction for a specific account, so it do creates a new account if it doesn't exist, logs the transaction if logging is True, and updates the account's balance and box.
1822
1823        Parameters:
1824        - unscaled_value (float | int | decimal.Decimal, optional): The value of the transaction. Default is 0.
1825        - desc (str, optional): The description of the transaction. Default is an empty string.
1826        - account (AccountName, optional): The account for which the transaction is being tracked. Default is '1'.
1827        - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, it will be generated. Default is None.
1828        - debug (bool, optional): Whether to print debug information. Default is False.
1829
1830        Returns:
1831        - Timestamp: The timestamp of the transaction in nanoseconds since epoch(1AD).
1832
1833        Raises:
1834        - ValueError: The created_time_ns should be greater than zero.
1835        - ValueError: The log transaction happened again in the same nanosecond time.
1836        - ValueError: The box transaction happened again in the same nanosecond time.
1837        """
1838        return self.__track(
1839            unscaled_value=unscaled_value,
1840            desc=desc,
1841            account=account,
1842            logging=True,
1843            created_time_ns=created_time_ns,
1844            debug=debug,
1845        )
1846
1847    def __track(self, unscaled_value: float | int | decimal.Decimal = 0, desc: str = '', account: AccountName = AccountName('1'),
1848              logging: bool = True,
1849              created_time_ns: Optional[Timestamp] = None,
1850              debug: bool = False) -> Timestamp:
1851        """
1852        Internal function to track a transaction.
1853
1854        This function handles the core logic for tracking a transaction, including account creation, logging, and box creation.
1855
1856        Parameters:
1857        - unscaled_value (float | int | decimal.Decimal, optional): The monetary value of the transaction. Defaults to 0.
1858        - desc (str, optional): A description of the transaction. Defaults to an empty string.
1859        - account (AccountName, optional): The name of the account to track the transaction for. Defaults to '1'.
1860        - logging (bool, optional): Enables transaction logging. Defaults to True.
1861        - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since the epoch. If not provided, the current time is used. Defaults to None.
1862        - debug (bool, optional): Enables debug printing. Defaults to False.
1863
1864        Returns:
1865        - Timestamp: The timestamp of the transaction in nanoseconds since the epoch.
1866
1867        Raises:
1868        - ValueError: If `created_time_ns` is not greater than zero.
1869        - ValueError: If a box transaction already exists for the given `account` and `created_time_ns`.
1870        """
1871        if debug:
1872            print('track', f'unscaled_value={unscaled_value}, debug={debug}')
1873        if created_time_ns is None:
1874            created_time_ns = Time.time()
1875        if created_time_ns <= 0:
1876            raise ValueError('The created should be greater than zero.')
1877        no_lock = self.nolock()
1878        lock = self.__lock()
1879        if not self.account_exists(account):
1880            if debug:
1881                print(f'account {account} created')
1882            self.__vault.account[account] = Account(
1883                balance=0,
1884                created=created_time_ns,
1885            )
1886            self.__step(Action.CREATE, account)
1887        if unscaled_value == 0:
1888            if no_lock:
1889                assert lock is not None
1890                self.free(lock)
1891            return NO_TIME()
1892        value = self.scale(unscaled_value)
1893        if logging:
1894            self.__log(value=value, desc=desc, account=account, created_time_ns=created_time_ns, ref=None, debug=debug)
1895        if debug:
1896            print('create-box', created_time_ns)
1897        if self.box_exists(account, created_time_ns):
1898            raise ValueError(f'The box transaction happened again in the same nanosecond time({created_time_ns}).')
1899        if debug:
1900            print('created-box', created_time_ns)
1901        self.__vault.account[account].box[created_time_ns] = Box(
1902            capital=value,
1903            count=0,
1904            last=0,
1905            rest=value,
1906            total=0,
1907        )
1908        self.__step(Action.TRACK, account, ref=created_time_ns, value=value)
1909        if no_lock:
1910            assert lock is not None
1911            self.free(lock)
1912        return created_time_ns
1913
1914    def log_exists(self, account: AccountName, ref: Timestamp) -> bool:
1915        """
1916        Checks if a specific transaction log entry exists for a given account.
1917
1918        Parameters:
1919        - account (AccountName): The account number associated with the transaction log.
1920        - ref (Timestamp): The reference to the transaction log entry.
1921
1922        Returns:
1923        - bool: True if the transaction log entry exists, False otherwise.
1924        """
1925        return self.ref_exists(account, 'log', ref)
1926
1927    def __log(self, value: int, desc: str = '', account: AccountName = AccountName('1'),
1928             created_time_ns: Optional[Timestamp] = None,
1929             ref: Optional[Timestamp] = None,
1930             debug: bool = False) -> Timestamp:
1931        """
1932        Log a transaction into the account's log by updates the account's balance, count, and log with the transaction details.
1933        It also creates a step in the history of the transaction.
1934
1935        Parameters:
1936        - value (int): The value of the transaction.
1937        - desc (str, optional): The description of the transaction.
1938        - account (AccountName, optional): The account to log the transaction into. Default is '1'.
1939        - created_time_ns (int, optional): The timestamp of the transaction in nanoseconds since epoch(1AD).
1940                                           If not provided, it will be generated.
1941        - ref (Timestamp, optional): The reference of the object.
1942        - debug (bool, optional): Whether to print debug information. Default is False.
1943
1944        Returns:
1945        - Timestamp: The timestamp of the logged transaction.
1946
1947        Raises:
1948        - ValueError: The created_time_ns should be greater than zero.
1949        - ValueError: The log transaction happened again in the same nanosecond time.
1950        """
1951        if debug:
1952            print('_log', f'debug={debug}')
1953        if created_time_ns is None:
1954            created_time_ns = Time.time()
1955        if created_time_ns <= 0:
1956            raise ValueError('The created should be greater than zero.')
1957        try:
1958            self.__vault.account[account].balance += value
1959        except TypeError:
1960            self.__vault.account[account].balance += decimal.Decimal(value)
1961        self.__vault.account[account].count += 1
1962        if debug:
1963            print('create-log', created_time_ns)
1964        if self.log_exists(account, created_time_ns):
1965            raise ValueError(f'The log transaction happened again in the same nanosecond time({created_time_ns}).')
1966        if debug:
1967            print('created-log', created_time_ns)
1968        self.__vault.account[account].log[created_time_ns] = Log(
1969            value=value,
1970            desc=desc,
1971            ref=ref,
1972            file={},            
1973        )
1974        self.__step(Action.LOG, account, ref=created_time_ns, value=value)
1975        return created_time_ns
1976
1977    def exchange(self, account: AccountName, created_time_ns: Optional[Timestamp] = None,
1978                 rate: Optional[float] = None, description: Optional[str] = None, debug: bool = False) -> Exchange:
1979        """
1980        This method is used to record or retrieve exchange rates for a specific account.
1981
1982        Parameters:
1983        - account (AccountName): The account number for which the exchange rate is being recorded or retrieved.
1984        - created_time_ns (Timestamp, optional): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
1985        - rate (float, optional): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
1986        - description (str, optional): A description of the exchange rate.
1987        - debug (bool, optional): Whether to print debug information. Default is False.
1988
1989        Returns:
1990        - Exchange: A dictionary containing the latest exchange rate and its description. If no exchange rate is found,
1991        it returns a dictionary with default values for the rate and description.
1992
1993        Raises:
1994        - ValueError: The created should be greater than zero.
1995        """
1996        if debug:
1997            print('exchange', f'debug={debug}')
1998        if created_time_ns is None:
1999            created_time_ns = Time.time()
2000        if created_time_ns <= 0:
2001            raise ValueError('The created should be greater than zero.')
2002        if rate is not None:
2003            if rate <= 0:
2004                return Exchange()
2005            if account not in self.__vault.exchange:
2006                self.__vault.exchange[account] = {}
2007            if len(self.__vault.exchange[account]) == 0 and rate <= 1:
2008                return Exchange(time=created_time_ns, rate=1)
2009            no_lock = self.nolock()
2010            lock = self.__lock()
2011            self.__vault.exchange[account][created_time_ns] = Exchange(rate=rate, description=description)
2012            self.__step(Action.EXCHANGE, account, ref=created_time_ns, value=rate)
2013            if no_lock:
2014                assert lock is not None
2015                self.free(lock)
2016            if debug:
2017                print('exchange-created-1',
2018                      f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}')
2019
2020        if account in self.__vault.exchange:
2021            valid_rates = [(ts, r) for ts, r in self.__vault.exchange[account].items() if ts <= created_time_ns]
2022            if valid_rates:
2023                latest_rate = max(valid_rates, key=lambda x: x[0])
2024                if debug:
2025                    print('exchange-read-1',
2026                          f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}',
2027                          'latest_rate', latest_rate)
2028                result = latest_rate[1]
2029                result.time = latest_rate[0]
2030                return result  # إرجاع قاموس يحتوي على المعدل والوصف
2031        if debug:
2032            print('exchange-read-0', f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}')
2033        return Exchange(time=created_time_ns, rate=1, description=None)  # إرجاع القيمة الافتراضية مع وصف فارغ
2034
2035    @staticmethod
2036    def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
2037        """
2038        This function calculates the exchanged amount of a currency.
2039
2040        Parameters:
2041        - x (float): The original amount of the currency.
2042        - x_rate (float): The exchange rate of the original currency.
2043        - y_rate (float): The exchange rate of the target currency.
2044
2045        Returns:
2046        - float: The exchanged amount of the target currency.
2047        """
2048        return (x * x_rate) / y_rate
2049
2050    def exchanges(self) -> dict:
2051        """
2052        Retrieve the recorded exchange rates for all accounts.
2053
2054        Parameters:
2055        None
2056
2057        Returns:
2058        - dict: A dictionary containing all recorded exchange rates.
2059        The keys are account names or numbers, and the values are dictionaries containing the exchange rates.
2060        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
2061        """
2062        return self.__vault.exchange.copy()
2063
2064    def accounts(self) -> dict:
2065        """
2066        Returns a dictionary containing account numbers as keys and their respective balances as values.
2067
2068        Parameters:
2069        None
2070
2071        Returns:
2072        - dict: A dictionary where keys are account numbers and values are their respective balances.
2073        """
2074        result = {}
2075        for i in self.__vault.account:
2076            result[i] = self.__vault.account[i].balance
2077        return result
2078
2079    def boxes(self, account: AccountName) -> dict:
2080        """
2081        Retrieve the boxes (transactions) associated with a specific account.
2082
2083        Parameters:
2084        - account (AccountName): The account number for which to retrieve the boxes.
2085
2086        Returns:
2087        - dict: A dictionary containing the boxes associated with the given account.
2088        If the account does not exist, an empty dictionary is returned.
2089        """
2090        if self.account_exists(account):
2091            return self.__vault.account[account].box
2092        return {}
2093
2094    def logs(self, account: AccountName) -> dict[Timestamp, Log]:
2095        """
2096        Retrieve the logs (transactions) associated with a specific account.
2097
2098        Parameters:
2099        - account (AccountName): The account number for which to retrieve the logs.
2100
2101        Returns:
2102        - dict[Timestamp, Log]: A dictionary containing the logs associated with the given account.
2103        If the account does not exist, an empty dictionary is returned.
2104        """
2105        if self.account_exists(account):
2106            return self.__vault.account[account].log
2107        return {}
2108
2109    @staticmethod
2110    def daily_logs_init() -> dict[str, dict]:
2111        """
2112        Initialize a dictionary to store daily, weekly, monthly, and yearly logs.
2113
2114        Parameters:
2115        None
2116
2117        Returns:
2118        - dict: A dictionary with keys 'daily', 'weekly', 'monthly', and 'yearly', each containing an empty dictionary.
2119            Later each key maps to another dictionary, which will store the logs for the corresponding time period.
2120        """
2121        return {
2122            'daily': {},
2123            'weekly': {},
2124            'monthly': {},
2125            'yearly': {},
2126        }
2127
2128    def daily_logs(self, weekday: WeekDay = WeekDay.FRIDAY, debug: bool = False):
2129        """
2130        Retrieve the daily logs (transactions) from all accounts.
2131
2132        The function groups the logs by day, month, and year, and calculates the total value for each group.
2133        It returns a dictionary where the keys are the timestamps of the daily groups,
2134        and the values are dictionaries containing the total value and the logs for that group.
2135
2136        Parameters:
2137        - weekday (WeekDay, optional): Select the weekday is collected for the week data. Default is WeekDay.Friday.
2138        - debug (bool, optional): Whether to print debug information. Default is False.
2139
2140        Returns:
2141        - dict: A dictionary containing the daily logs.
2142
2143        Example:
2144        ```bash
2145        >>> tracker = ZakatTracker()
2146        >>> tracker.subtract(51, 'desc', 'account1')
2147        >>> ref = tracker.track(100, 'desc', 'account2')
2148        >>> tracker.add_file('account2', ref, 'file_0')
2149        >>> tracker.add_file('account2', ref, 'file_1')
2150        >>> tracker.add_file('account2', ref, 'file_2')
2151        >>> tracker.daily_logs()
2152        {
2153            'daily': {
2154                '2024-06-30': {
2155                    'positive': 100,
2156                    'negative': 51,
2157                    'total': 99,
2158                    'rows': [
2159                        {
2160                            'account': 'account1',
2161                            'desc': 'desc',
2162                            'file': {},
2163                            'ref': None,
2164                            'value': -51,
2165                            'time': 1690977015000000000,
2166                            'transfer': False,
2167                        },
2168                        {
2169                            'account': 'account2',
2170                            'desc': 'desc',
2171                            'file': {
2172                                1722919011626770944: 'file_0',
2173                                1722919011626812928: 'file_1',
2174                                1722919011626846976: 'file_2',
2175                            },
2176                            'ref': None,
2177                            'value': 100,
2178                            'time': 1690977015000000000,
2179                            'transfer': False,
2180                        },
2181                    ],
2182                },
2183            },
2184            'weekly': {
2185                datetime: {
2186                    'positive': 100,
2187                    'negative': 51,
2188                    'total': 99,
2189                },
2190            },
2191            'monthly': {
2192                '2024-06': {
2193                    'positive': 100,
2194                    'negative': 51,
2195                    'total': 99,
2196                },
2197            },
2198            'yearly': {
2199                2024: {
2200                    'positive': 100,
2201                    'negative': 51,
2202                    'total': 99,
2203                },
2204            },
2205        }
2206        ```
2207        """
2208        logs = {}
2209        for account in self.accounts():
2210            for k, v in self.logs(account).items():
2211                l = dataclasses.asdict(v)
2212                l['time'] = k
2213                l['account'] = account
2214                if k not in logs:
2215                    logs[k] = []
2216                logs[k].append(l)
2217        if debug:
2218            print('logs', logs)
2219        y = self.daily_logs_init()
2220        for i in sorted(logs, reverse=True):
2221            dt = Time.time_to_datetime(i)
2222            daily = f'{dt.year}-{dt.month:02d}-{dt.day:02d}'
2223            weekly = dt - datetime.timedelta(days=weekday.value)
2224            monthly = f'{dt.year}-{dt.month:02d}'
2225            yearly = dt.year
2226            # daily
2227            if daily not in y['daily']:
2228                y['daily'][daily] = {
2229                    'positive': 0,
2230                    'negative': 0,
2231                    'total': 0,
2232                    'rows': [],
2233                }
2234            transfer = len(logs[i]) > 1
2235            if debug:
2236                print('logs[i]', logs[i])
2237            for z in logs[i]:
2238                if debug:
2239                    print('z', z)
2240                # daily
2241                value = z['value']
2242                if value > 0:
2243                    y['daily'][daily]['positive'] += value
2244                else:
2245                    y['daily'][daily]['negative'] += -value
2246                y['daily'][daily]['total'] += value
2247                z['transfer'] = transfer
2248                y['daily'][daily]['rows'].append(z)
2249                # weekly
2250                if weekly not in y['weekly']:
2251                    y['weekly'][weekly] = {
2252                        'positive': 0,
2253                        'negative': 0,
2254                        'total': 0,
2255                    }
2256                if value > 0:
2257                    y['weekly'][weekly]['positive'] += value
2258                else:
2259                    y['weekly'][weekly]['negative'] += -value
2260                y['weekly'][weekly]['total'] += value
2261                # monthly
2262                if monthly not in y['monthly']:
2263                    y['monthly'][monthly] = {
2264                        'positive': 0,
2265                        'negative': 0,
2266                        'total': 0,
2267                    }
2268                if value > 0:
2269                    y['monthly'][monthly]['positive'] += value
2270                else:
2271                    y['monthly'][monthly]['negative'] += -value
2272                y['monthly'][monthly]['total'] += value
2273                # yearly
2274                if yearly not in y['yearly']:
2275                    y['yearly'][yearly] = {
2276                        'positive': 0,
2277                        'negative': 0,
2278                        'total': 0,
2279                    }
2280                if value > 0:
2281                    y['yearly'][yearly]['positive'] += value
2282                else:
2283                    y['yearly'][yearly]['negative'] += -value
2284                y['yearly'][yearly]['total'] += value
2285        if debug:
2286            print('y', y)
2287        return y
2288
2289    def add_file(self, account: AccountName, ref: Timestamp, path: str) -> Timestamp:
2290        """
2291        Adds a file reference to a specific transaction log entry in the vault.
2292
2293        Parameters:
2294        - account (AccountName): The account number associated with the transaction log.
2295        - ref (Timestamp): The reference to the transaction log entry.
2296        - path (str): The path of the file to be added.
2297
2298        Returns:
2299        - Timestamp: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
2300        """
2301        if self.account_exists(account):
2302            if ref in self.__vault.account[account].log:
2303                no_lock = self.nolock()
2304                lock = self.__lock()
2305                file_ref = Time.time()
2306                self.__vault.account[account].log[ref].file[file_ref] = path
2307                self.__step(Action.ADD_FILE, account, ref=ref, file=file_ref)
2308                if no_lock:
2309                    assert lock is not None
2310                    self.free(lock)
2311                return file_ref
2312        return Timestamp(0)
2313
2314    def remove_file(self, account: AccountName, ref: Timestamp, file_ref: Timestamp) -> bool:
2315        """
2316        Removes a file reference from a specific transaction log entry in the vault.
2317
2318        Parameters:
2319        - account (AccountName): The account number associated with the transaction log.
2320        - ref (Timestamp): The reference to the transaction log entry.
2321        - file_ref (Timestamp): The reference of the file to be removed.
2322
2323        Returns:
2324        - bool: True if the file reference is successfully removed, False otherwise.
2325        """
2326        if self.account_exists(account):
2327            if ref in self.__vault.account[account].log:
2328                if file_ref in self.__vault.account[account].log[ref].file:
2329                    no_lock = self.nolock()
2330                    lock = self.__lock()
2331                    x = self.__vault.account[account].log[ref].file[file_ref]
2332                    del self.__vault.account[account].log[ref].file[file_ref]
2333                    self.__step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x)
2334                    if no_lock:
2335                        assert lock is not None
2336                        self.free(lock)
2337                    return True
2338        return False
2339
2340    def balance(self, account: AccountName = AccountName('1'), cached: bool = True) -> int:
2341        """
2342        Calculate and return the balance of a specific account.
2343
2344        Parameters:
2345        - account (AccountName, optional): The account number. Default is '1'.
2346        - cached (bool, optional): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
2347
2348        Returns:
2349        - int: The balance of the account.
2350
2351        Notes:
2352        - If cached is True, the function returns the cached balance.
2353        - If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
2354        """
2355        if cached:
2356            return self.__vault.account[account].balance
2357        x = 0
2358        return [x := x + y.rest for y in self.__vault.account[account].box.values()][-1]
2359
2360    def hide(self, account: AccountName, status: Optional[bool] = None) -> bool:
2361        """
2362        Check or set the hide status of a specific account.
2363
2364        Parameters:
2365        - account (AccountName): The account number.
2366        - status (bool, optional): The new hide status. If not provided, the function will return the current status.
2367
2368        Returns:
2369        - bool: The current or updated hide status of the account.
2370
2371        Raises:
2372        None
2373
2374        Example:
2375        ```bash
2376        >>> tracker = ZakatTracker()
2377        >>> ref = tracker.track(51, 'desc', 'account1')
2378        >>> tracker.hide('account1')  # Set the hide status of 'account1' to True
2379        False
2380        >>> tracker.hide('account1', True)  # Set the hide status of 'account1' to True
2381        True
2382        >>> tracker.hide('account1')  # Get the hide status of 'account1' by default
2383        True
2384        >>> tracker.hide('account1', False)
2385        False
2386        ```
2387        """
2388        if self.account_exists(account):
2389            if status is None:
2390                return self.__vault.account[account].hide
2391            self.__vault.account[account].hide = status
2392            return status
2393        return False
2394
2395    def zakatable(self, account: AccountName, status: Optional[bool] = None) -> bool:
2396        """
2397        Check or set the zakatable status of a specific account.
2398
2399        Parameters:
2400        - account (AccountName): The account number.
2401        - status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
2402
2403        Returns:
2404        - bool: The current or updated zakatable status of the account.
2405
2406        Raises:
2407        None
2408
2409        Example:
2410        ```bash
2411        >>> tracker = ZakatTracker()
2412        >>> ref = tracker.track(51, 'desc', 'account1')
2413        >>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
2414        True
2415        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
2416        True
2417        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
2418        True
2419        >>> tracker.zakatable('account1', False)
2420        False
2421        ```
2422        """
2423        if self.account_exists(account):
2424            if status is None:
2425                return self.__vault.account[account].zakatable
2426            self.__vault.account[account].zakatable = status
2427            return status
2428        return False
2429
2430    def subtract(self, unscaled_value: float | int | decimal.Decimal, desc: str = '', account: AccountName = AccountName('1'),
2431            created_time_ns: Optional[Timestamp] = None,
2432            debug: bool = False) \
2433            -> SubtractReport:
2434        """
2435        Subtracts a specified value from an account's balance, if the amount to subtract is greater than the account's balance,
2436        the remaining amount will be transferred to a new transaction with a negative value.
2437
2438        Parameters:
2439        - unscaled_value (float | int | decimal.Decimal): The amount to be subtracted.
2440        - desc (str, optional): A description for the transaction. Defaults to an empty string.
2441        - account (AccountName, optional): The account from which the value will be subtracted. Defaults to '1'.
2442        - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD).
2443                                           If not provided, the current timestamp will be used.
2444        - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False.
2445
2446        Returns:
2447        - SubtractReport: A class containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
2448
2449        Raises:
2450        - ValueError: The unscaled_value should be greater than zero.
2451        - ValueError: The created_time_ns should be greater than zero.
2452        - ValueError: The box transaction happened again in the same nanosecond time.
2453        - ValueError: The log transaction happened again in the same nanosecond time.
2454        """
2455        if debug:
2456            print('sub', f'debug={debug}')
2457        if unscaled_value <= 0:
2458            raise ValueError('The unscaled_value should be greater than zero.')
2459        if created_time_ns is None:
2460            created_time_ns = Time.time()
2461        if created_time_ns <= 0:
2462            raise ValueError('The created should be greater than zero.')
2463        no_lock = self.nolock()
2464        lock = self.__lock()
2465        self.__track(0, '', account)
2466        value = self.scale(unscaled_value)
2467        self.__log(value=-value, desc=desc, account=account, created_time_ns=created_time_ns, ref=None, debug=debug)
2468        ids = sorted(self.__vault.account[account].box.keys())
2469        limit = len(ids) + 1
2470        target = value
2471        if debug:
2472            print('ids', ids)
2473        ages = SubtractAges()
2474        for i in range(-1, -limit, -1):
2475            if target == 0:
2476                break
2477            j = ids[i]
2478            if debug:
2479                print('i', i, 'j', j)
2480            rest = self.__vault.account[account].box[j].rest
2481            if rest >= target:
2482                self.__vault.account[account].box[j].rest -= target
2483                self.__step(Action.SUBTRACT, account, ref=j, value=target)
2484                ages.append(SubtractAge(box_ref=j, total=target))
2485                target = 0
2486                break
2487            elif target > rest > 0:
2488                chunk = rest
2489                target -= chunk
2490                self.__vault.account[account].box[j].rest = 0
2491                self.__step(Action.SUBTRACT, account, ref=j, value=chunk)
2492                ages.append(SubtractAge(box_ref=j, total=chunk))
2493        if target > 0:
2494            self.__track(
2495                unscaled_value=self.unscale(-target),
2496                desc=desc,
2497                account=account,
2498                logging=False,
2499                created_time_ns=created_time_ns,
2500            )
2501            ages.append(SubtractAge(box_ref=created_time_ns, total=target))
2502        if no_lock:
2503            assert lock is not None
2504            self.free(lock)
2505        return SubtractReport(
2506            log_ref=created_time_ns,
2507            ages=ages,
2508        )
2509
2510    def transfer(self, unscaled_amount: float | int | decimal.Decimal, from_account: AccountName, to_account: AccountName, desc: str = '',
2511                 created_time_ns: Optional[Timestamp] = None,
2512                 debug: bool = False) -> TransferReport:
2513        """
2514        Transfers a specified value from one account to another.
2515
2516        Parameters:
2517        - unscaled_amount (float | int | decimal.Decimal): The amount to be transferred.
2518        - from_account (AccountName): The account from which the value will be transferred.
2519        - to_account (AccountName): The account to which the value will be transferred.
2520        - desc (str, optional): A description for the transaction. Defaults to an empty string.
2521        - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, the current timestamp will be used.
2522        - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False.
2523
2524        Returns:
2525        - TransferReport: A class of timestamps corresponding to the transactions made during the transfer.
2526
2527        Raises:
2528        - ValueError: Transfer to the same account is forbidden.
2529        - ValueError: The created_time_ns should be greater than zero.
2530        - ValueError: The box transaction happened again in the same nanosecond time.
2531        - ValueError: The log transaction happened again in the same nanosecond time.
2532        """
2533        if debug:
2534            print('transfer', f'debug={debug}')
2535        if from_account == to_account:
2536            raise ValueError(f'Transfer to the same account is forbidden. {to_account}')
2537        if unscaled_amount <= 0:
2538            return []
2539        if created_time_ns is None:
2540            created_time_ns = Time.time()
2541        if created_time_ns <= 0:
2542            raise ValueError('The created should be greater than zero.')
2543        no_lock = self.nolock()
2544        lock = self.__lock()
2545        subtract_report = self.subtract(unscaled_amount, desc, from_account, created_time_ns, debug=debug)
2546        source_exchange = self.exchange(from_account, created_time_ns)
2547        target_exchange = self.exchange(to_account, created_time_ns)
2548
2549        if debug:
2550            print('ages', subtract_report.ages)
2551
2552        transfer_report = TransferReport()
2553        for subtract in subtract_report.ages:
2554            times = TransferTimes()
2555            age = subtract.box_ref
2556            value = subtract.total
2557            assert source_exchange.rate is not None
2558            assert target_exchange.rate is not None
2559            target_amount = int(self.exchange_calc(value, source_exchange.rate, target_exchange.rate))
2560            if debug:
2561                print('target_amount', target_amount)
2562            # Perform the transfer
2563            if self.box_exists(to_account, age):
2564                if debug:
2565                    print('box_exists', age)
2566                capital = self.__vault.account[to_account].box[age].capital
2567                rest = self.__vault.account[to_account].box[age].rest
2568                if debug:
2569                    print(
2570                        f'Transfer(loop) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).')
2571                selected_age = age
2572                if rest + target_amount > capital:
2573                    self.__vault.account[to_account].box[age].capital += target_amount
2574                    selected_age = Time.time()
2575                self.__vault.account[to_account].box[age].rest += target_amount
2576                self.__step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount)
2577                y = self.__log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account,
2578                              created_time_ns=None, ref=None, debug=debug)
2579                times.append(TransferTime(box_ref=age, log_ref=y))
2580                continue
2581            if debug:
2582                print(
2583                    f'Transfer(func) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).')
2584            box_ref = self.__track(
2585                unscaled_value=self.unscale(int(target_amount)),
2586                desc=desc,
2587                account=to_account,
2588                logging=True,
2589                created_time_ns=age,
2590                debug=debug,
2591            )
2592            transfer_report.append(TransferRecord(
2593                box_ref=box_ref,
2594                times=times,
2595            ))
2596        if no_lock:
2597            assert lock is not None
2598            self.free(lock)
2599        return transfer_report
2600    
2601    def check(self,
2602              silver_gram_price: float,
2603              unscaled_nisab: Optional[float | int | decimal.Decimal] = None,
2604              debug: bool = False,
2605              created_time_ns: Optional[Timestamp] = None,
2606              cycle: Optional[float] = None) -> ZakatReport:
2607        """
2608        Check the eligibility for Zakat based on the given parameters.
2609
2610        Parameters:
2611        - silver_gram_price (float): The price of a gram of silver.
2612        - unscaled_nisab (float | int | decimal.Decimal, optional): The minimum amount of wealth required for Zakat.
2613                        If not provided, it will be calculated based on the silver_gram_price.
2614        - debug (bool, optional): Flag to enable debug mode.
2615        - created_time_ns (Timestamp, optional): The current timestamp. If not provided, it will be calculated using Time.time().
2616        - cycle (float, optional): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
2617
2618        Returns:
2619        - ZakatReport: A tuple containing a boolean indicating the eligibility for Zakat,
2620            a list of brief statistics, and a dictionary containing the Zakat plan.
2621        """
2622        if debug:
2623            print('check', f'debug={debug}')
2624        if created_time_ns is None:
2625            created_time_ns = Time.time()
2626        if cycle is None:
2627            cycle = ZakatTracker.TimeCycle()
2628        if unscaled_nisab is None:
2629            unscaled_nisab = ZakatTracker.Nisab(silver_gram_price)
2630        nisab = self.scale(unscaled_nisab)
2631        plan = ZakatPlan()
2632        statistics = ZakatReportStatistics()
2633        below_nisab = 0
2634        valid = False
2635        if debug:
2636            print('exchanges', self.exchanges())
2637        for x in self.__vault.account:
2638            if not self.zakatable(x):
2639                continue
2640            _box = self.__vault.account[x].box
2641            _log = self.__vault.account[x].log
2642            limit = len(_box) + 1
2643            ids = sorted(self.__vault.account[x].box.keys())
2644            for i in range(-1, -limit, -1):
2645                j = ids[i]
2646                rest = float(_box[j].rest)
2647                if rest <= 0:
2648                    continue
2649                exchange = self.exchange(x, created_time_ns=Time.time())
2650                assert exchange.rate is not None
2651                rest = ZakatTracker.exchange_calc(rest, float(exchange.rate), 1)
2652                statistics.overall_wealth += rest
2653                epoch = (created_time_ns - j) / cycle
2654                if debug:
2655                    print(f'Epoch: {epoch}', _box[j])
2656                if _box[j].last > 0:
2657                    epoch = (created_time_ns - _box[j].last) / cycle
2658                if debug:
2659                    print(f'Epoch: {epoch}')
2660                epoch = math.floor(epoch)
2661                if debug:
2662                    print(f'Epoch: {epoch}', type(epoch), epoch == 0, 1 - epoch, epoch)
2663                if epoch == 0:
2664                    continue
2665                if debug:
2666                    print('Epoch - PASSED')
2667                statistics.zakatable_transactions_balance += rest
2668                is_nisab = rest >= nisab
2669                total = 0
2670                if is_nisab:
2671                    for _ in range(epoch):
2672                        total += ZakatTracker.ZakatCut(float(rest) - float(total))
2673                    valid = total > 0
2674                elif rest > 0:
2675                    below_nisab += rest
2676                    total = ZakatTracker.ZakatCut(float(rest))
2677                if total > 0:
2678                    if x not in plan:
2679                        plan[x] = []
2680                    statistics.zakat_cut_balances += total
2681                    plan[x].append(BoxPlan(
2682                        below_nisab=not is_nisab,
2683                        total=total,
2684                        count=epoch,
2685                        ref=j,
2686                        box=_box[j],
2687                        log=_log[j],
2688                        exchange=exchange,
2689                    ))
2690        valid = valid or below_nisab >= nisab
2691        if debug:
2692            print(f'below_nisab({below_nisab}) >= nisab({nisab})')
2693        return ZakatReport(
2694            valid=valid,
2695            statistics=statistics,
2696            plan=plan,
2697        )
2698
2699    def build_payment_parts(self, scaled_demand: int, positive_only: bool = True) -> PaymentParts:
2700        """
2701        Build payment parts for the Zakat distribution.
2702
2703        Parameters:
2704        - scaled_demand (int): The total demand for payment in local currency.
2705        - positive_only (bool, optional): If True, only consider accounts with positive balance. Default is True.
2706
2707        Returns:
2708        - PaymentParts: A dictionary containing the payment parts for each account. The dictionary has the following structure:
2709        {
2710            'account': {
2711                'account_id': {'balance': float, 'rate': float, 'part': float},
2712                ...
2713            },
2714            'exceed': bool,
2715            'demand': int,
2716            'total': float,
2717        }
2718        """
2719        total = 0.0
2720        parts = PaymentParts(
2721            account={},
2722            exceed=False,
2723            demand=int(round(scaled_demand)),
2724            total=0,
2725        )
2726        for x, y in self.accounts().items():
2727            if positive_only and y <= 0:
2728                continue
2729            total += float(y)
2730            exchange = self.exchange(x)
2731            parts.account[x] = AccountPaymentPart(balance=y, rate=exchange.rate, part=0)
2732        parts.total = total
2733        return parts
2734
2735    @staticmethod
2736    def check_payment_parts(parts: PaymentParts, debug: bool = False) -> int:
2737        """
2738        Checks the validity of payment parts.
2739
2740        Parameters:
2741        - parts (dict[str, PaymentParts): A dictionary containing payment parts information.
2742        - debug (bool, optional): Flag to enable debug mode.
2743
2744        Returns:
2745        - int: Returns 0 if the payment parts are valid, otherwise returns the error code.
2746
2747        Error Codes:
2748        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
2749        2: 'balance', 'rate' or 'part' key is missing in parts['account'][x].
2750        3: 'part' value in parts['account'][x] is less than 0.
2751        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
2752        5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
2753        6: The sum of 'part' values in parts['account'] does not match with 'demand' value.
2754        """
2755        if debug:
2756            print('check_payment_parts', f'debug={debug}')
2757        # for i in ['demand', 'account', 'total', 'exceed']:
2758        #     if i not in parts:
2759        #         return 1
2760        exceed = parts.exceed
2761        # for j in ['balance', 'rate', 'part']:
2762        #     if j not in parts.account[x]:
2763        #         return 2
2764        for x in parts.account:
2765            if parts.account[x].part < 0:
2766                return 3
2767            if not exceed and parts.account[x].balance <= 0:
2768                return 4
2769        demand = parts.demand
2770        z = 0.0
2771        for _, y in parts.account.items():
2772            if not exceed and y.part > y.balance:
2773                return 5
2774            z += ZakatTracker.exchange_calc(y.part, y.rate, 1.0)
2775        z = round(z, 2)
2776        demand = round(demand, 2)
2777        if debug:
2778            print('check_payment_parts', f'z = {z}, demand = {demand}')
2779            print('check_payment_parts', type(z), type(demand))
2780            print('check_payment_parts', z != demand)
2781            print('check_payment_parts', str(z) != str(demand))
2782        if z != demand and str(z) != str(demand):
2783            return 6
2784        return 0
2785
2786    def zakat(self, report: ZakatReport,
2787        parts: Optional[PaymentParts] = None, debug: bool = False) -> bool:
2788        """
2789        Perform Zakat calculation based on the given report and optional parts.
2790
2791        Parameters:
2792        - report (ZakatReport): A dataclass containing the validity of the report, the report data, and the zakat plan.
2793        - parts (PaymentParts, optional): A dictionary containing the payment parts for the zakat.
2794        - debug (bool, optional): A flag indicating whether to print debug information.
2795
2796        Returns:
2797        - bool: True if the zakat calculation is successful, False otherwise.
2798        """
2799        if debug:
2800            print('zakat', f'debug={debug}')
2801        if not report.valid:
2802            return report.valid
2803        parts_exist = parts is not None
2804        if parts_exist:
2805            if self.check_payment_parts(parts, debug=debug) != 0:
2806                return False
2807        if debug:
2808            print('######### zakat #######')
2809            print('parts_exist', parts_exist)
2810        no_lock = self.nolock()
2811        lock = self.__lock()
2812        report_time = Time.time()
2813        self.__vault.report[report_time] = report
2814        self.__step(Action.REPORT, ref=report_time)
2815        created_time_ns = Time.time()
2816        for x in report.plan:
2817            target_exchange = self.exchange(x)
2818            if debug:
2819                print(report.plan[x])
2820                print('-------------')
2821                print(self.__vault.account[x].box)
2822            if debug:
2823                print('plan[x]', report.plan[x])
2824            for plan in report.plan[x]:
2825                j = plan.ref
2826                if debug:
2827                    print('j', j)
2828                assert j
2829                self.__step(Action.ZAKAT, account=x, ref=j, value=self.__vault.account[x].box[j].last,
2830                           key='last',
2831                           math_operation=MathOperation.EQUAL)
2832                self.__vault.account[x].box[j].last = created_time_ns
2833                assert target_exchange.rate is not None
2834                amount = ZakatTracker.exchange_calc(float(plan.total), 1, float(target_exchange.rate))
2835                self.__vault.account[x].box[j].total += amount
2836                self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='total',
2837                           math_operation=MathOperation.ADDITION)
2838                self.__vault.account[x].box[j].count += plan.count
2839                self.__step(Action.ZAKAT, account=x, ref=j, value=plan.count, key='count',
2840                           math_operation=MathOperation.ADDITION)
2841                if not parts_exist:
2842                    try:
2843                        self.__vault.account[x].box[j].rest -= amount
2844                    except TypeError:
2845                        self.__vault.account[x].box[j].rest -= decimal.Decimal(amount)
2846                    # self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest',
2847                    #            math_operation=MathOperation.SUBTRACTION)
2848                    self.__log(-float(amount), desc='zakat-زكاة', account=x, created_time_ns=None, ref=j, debug=debug)
2849        if parts_exist:
2850            for account, part in parts.account.items():
2851                if part.part == 0:
2852                    continue
2853                if debug:
2854                    print('zakat-part', account, part.rate)
2855                target_exchange = self.exchange(account)
2856                assert target_exchange.rate is not None
2857                amount = ZakatTracker.exchange_calc(part.part, part.rate, target_exchange.rate)
2858                self.subtract(
2859                    unscaled_value=self.unscale(int(amount)),
2860                    desc='zakat-part-دفعة-زكاة',
2861                    account=account,
2862                    debug=debug,
2863                )
2864        if no_lock:
2865            assert lock is not None
2866            self.free(lock)
2867        return True
2868
2869    @staticmethod
2870    def split_at_last_symbol(data: str, symbol: str) -> tuple[str, str]:
2871        """Splits a string at the last occurrence of a given symbol.
2872    
2873        Parameters:
2874        - data (str): The input string.
2875        - symbol (str): The symbol to split at.
2876    
2877        Returns:
2878        - tuple[str, str]: A tuple containing two strings, the part before the last symbol and
2879            the part after the last symbol. If the symbol is not found, returns (data, "").
2880        """
2881        last_symbol_index = data.rfind(symbol)
2882    
2883        if last_symbol_index != -1:
2884            before_symbol = data[:last_symbol_index]
2885            after_symbol = data[last_symbol_index + len(symbol):]
2886            return before_symbol, after_symbol
2887        return data, ""
2888
2889    def save(self, path: Optional[str] = None, hash_required: bool = True) -> bool:
2890        """
2891        Saves the ZakatTracker's current state to a json file.
2892
2893        This method serializes the internal data (`__vault`).
2894
2895        Parameters:
2896        - path (str, optional): File path for saving. Defaults to a predefined location.
2897        - hash_required (bool, optional): Whether to add the data integrity using a hash. Defaults to True.
2898
2899        Returns:
2900        - bool: True if the save operation is successful, False otherwise.
2901        """
2902        if path is None:
2903            path = self.path()
2904        # first save in tmp file
2905        temp = f'{path}.tmp'
2906        try:
2907            with open(temp, 'w', encoding='utf-8') as stream:
2908                data = json.dumps(self.__vault, cls=JSONEncoder)
2909                stream.write(data)
2910                if hash_required:
2911                    hashed = self.hash_data(data.encode())
2912                    stream.write(f'//{hashed}')
2913            # then move tmp file to original location
2914            shutil.move(temp, path)
2915            return True
2916        except (IOError, OSError) as e:
2917            print(f'Error saving file: {e}')
2918            if os.path.exists(temp):
2919                os.remove(temp)
2920            return False
2921
2922    @staticmethod
2923    def load_vault_from_json(json_string: str) -> Vault:
2924        """Loads a Vault dataclass from a JSON string."""
2925        data = json.loads(json_string)
2926
2927        vault = Vault()
2928
2929        # Load Accounts
2930        for account_name, account_data in data.get("account", {}).items():
2931            account_name = AccountName(account_name)
2932            box_data = account_data.get('box', {})
2933            box = {Timestamp(ts): Box(**box_data[str(ts)]) for ts in box_data}
2934
2935            log_data = account_data.get('log', {})
2936            log = {Timestamp(ts): Log(
2937                value=log_data[str(ts)]['value'],
2938                desc=log_data[str(ts)]['desc'],
2939                ref=Timestamp(log_data[str(ts)].get('ref')) if log_data[str(ts)].get('ref') is not None else None,
2940                file={Timestamp(ft): fv for ft, fv in log_data[str(ts)].get('file', {}).items()}
2941            ) for ts in log_data}
2942
2943            vault.account[account_name] = Account(
2944                balance=account_data["balance"],
2945                created=Timestamp(account_data["created"]),
2946                box=box,
2947                count=account_data.get("count", 0),
2948                log=log,
2949                hide=account_data.get("hide", False),
2950                zakatable=account_data.get("zakatable", True),
2951            )
2952
2953        # Load Exchanges
2954        for account_name, exchange_data in data.get("exchange", {}).items():
2955            account_name = AccountName(account_name)
2956            vault.exchange[account_name] = {}
2957            for timestamp, exchange_details in exchange_data.items():
2958                vault.exchange[account_name][Timestamp(timestamp)] = Exchange(
2959                    rate=exchange_details.get("rate"),
2960                    description=exchange_details.get("description"),
2961                    time=Timestamp(exchange_details.get("time")) if exchange_details.get("time") is not None else None
2962                )
2963
2964        # Load History
2965        for timestamp, history_list in data.get("history", {}).items():
2966            vault.history[Timestamp(timestamp)] = []
2967            for history_data in history_list:
2968                vault.history[Timestamp(timestamp)].append(History(
2969                    action=Action(history_data["action"]),
2970                    account=AccountName(history_data["account"]) if history_data.get("account") is not None else None,
2971                    ref=Timestamp(history_data.get("ref")) if history_data.get("ref") is not None else None,
2972                    file=Timestamp(history_data.get("file")) if history_data.get("file") is not None else None,
2973                    key=history_data.get("key"),
2974                    value=history_data.get("value"),
2975                    math=MathOperation(history_data.get("math")) if history_data.get("math") is not None else None
2976                ))
2977
2978        # Load Lock
2979        vault.lock = Timestamp(data["lock"]) if data.get("lock") is not None else None
2980
2981        # Load Report
2982        for timestamp, report_data in data.get("report", {}).items():
2983            zakat_plan = ZakatPlan()
2984            for account_name, box_plans in report_data.get("plan", {}).items():
2985                account_name = AccountName(account_name)
2986                zakat_plan[account_name] = []
2987                for box_plan_data in box_plans:
2988                    zakat_plan[account_name].append(BoxPlan(
2989                        box=Box(**box_plan_data["box"]),
2990                        log=Log(**box_plan_data["log"]),
2991                        exchange=Exchange(**box_plan_data["exchange"]),
2992                        below_nisab=box_plan_data["below_nisab"],
2993                        total=box_plan_data["total"],
2994                        count=box_plan_data["count"],
2995                        ref=Timestamp(box_plan_data["ref"])
2996                    ))
2997
2998            vault.report[Timestamp(timestamp)] = ZakatReport(
2999                valid=report_data["valid"],
3000                statistics=ZakatReportStatistics(**report_data["statistics"]),
3001                plan=zakat_plan
3002            )
3003
3004        return vault
3005
3006    def load(self, path: Optional[str] = None, hash_required: bool = True, debug: bool = False) -> bool:
3007        """
3008        Load the current state of the ZakatTracker object from a json file.
3009
3010        Parameters:
3011        - path (str, optional): The path where the json file is located. If not provided, it will use the default path.
3012        - hash_required (bool, optional): Whether to verify the data integrity using a hash. Defaults to True.
3013        - debug (bool, optional): Flag to enable debug mode.
3014
3015        Returns:
3016        - bool: True if the load operation is successful, False otherwise.
3017        """
3018        if path is None:
3019            path = self.path()
3020        try:
3021            if os.path.exists(path):
3022                with open(path, 'r', encoding='utf-8') as stream:
3023                    file = stream.read()
3024                    data, hashed = self.split_at_last_symbol(file, '//')
3025                    if hash_required:
3026                        assert hashed
3027                        if debug:
3028                            print('[debug-load]', hashed)
3029                        new_hash = self.hash_data(data.encode())
3030                        if debug:
3031                            print('[debug-load]', new_hash)
3032                        assert hashed == new_hash, "Hash verification failed. File may be corrupted."
3033                    self.__vault = self.load_vault_from_json(data)
3034                return True
3035            else:
3036                print(f'File not found: {path}')
3037                return False
3038        except (IOError, OSError) as e:
3039            print(f'Error loading file: {e}')
3040            return False
3041
3042    def import_csv_cache_path(self):
3043        """
3044        Generates the cache file path for imported CSV data.
3045
3046        This function constructs the file path where cached data from CSV imports
3047        will be stored. The cache file is a json file (.json extension) appended
3048        to the base path of the object.
3049
3050        Parameters:
3051        None
3052
3053        Returns:
3054        - str: The full path to the import CSV cache file.
3055
3056        Example:
3057        ```bash
3058        >>> obj = ZakatTracker('/data/reports')
3059        >>> obj.import_csv_cache_path()
3060        '/data/reports.import_csv.json'
3061        ```
3062        """
3063        path = str(self.path())
3064        ext = self.ext()
3065        ext_len = len(ext)
3066        if path.endswith(f'.{ext}'):
3067            path = path[:-ext_len - 1]
3068        _, filename = os.path.split(path + f'.import_csv.{ext}')
3069        return self.base_path(filename)
3070
3071    def import_csv(self, path: str = 'file.csv', scale_decimal_places: int = 0, debug: bool = False) -> tuple:
3072        """
3073        The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system.
3074
3075        Parameters:
3076        - path (str, optional): The path to the CSV file. Default is 'file.csv'.
3077        - scale_decimal_places (int, optional): The number of decimal places to scale the value. Default is 0.
3078        - debug (bool, optional): A flag indicating whether to print debug information.
3079
3080        Returns:
3081        - tuple: A tuple containing the number of transactions created, the number of transactions found in the cache,
3082                and a dictionary of bad transactions.
3083
3084        Notes:
3085        * Currency Pair Assumption: This function assumes that the exchange rates stored for each account
3086                                    are appropriate for the currency pairs involved in the conversions.
3087        * The exchange rate for each account is based on the last encountered transaction rate that is not equal
3088            to 1.0 or the previous rate for that account.
3089        * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent
3090            transactions of the same account within the whole imported and existing dataset when doing `check` and
3091            `zakat` operations.
3092
3093        Example:
3094            The CSV file should have the following format, rate is optional per transaction:
3095            account, desc, value, date, rate
3096            For example:
3097            safe-45, 'Some text', 34872, 1988-06-30 00:00:00, 1
3098        """
3099        if debug:
3100            print('import_csv', f'debug={debug}')
3101        cache: list[int] = []
3102        try:
3103            with open(self.import_csv_cache_path(), 'r', encoding='utf-8') as stream:
3104                cache = json.load(stream)
3105        except:
3106            pass
3107        date_formats = [
3108            '%Y-%m-%d %H:%M:%S',
3109            '%Y-%m-%dT%H:%M:%S',
3110            '%Y-%m-%dT%H%M%S.%f',
3111            '%Y-%m-%d',
3112        ]
3113        created, found, bad = 0, 0, {}
3114        data: dict[int, list] = {}
3115        with open(path, newline='', encoding='utf-8') as f:
3116            i = 0
3117            for row in csv.reader(f, delimiter=','):
3118                i += 1
3119                hashed = hash(tuple(row))
3120                if hashed in cache:
3121                    found += 1
3122                    continue
3123                account = row[0]
3124                desc = row[1]
3125                value = float(row[2])
3126                rate = 1.0
3127                if row[4:5]:  # Empty list if index is out of range
3128                    rate = float(row[4])
3129                date: int = 0
3130                for time_format in date_formats:
3131                    try:
3132                        date = Time.time(datetime.datetime.strptime(row[3], time_format))
3133                        break
3134                    except:
3135                        pass
3136                if date <= 0:
3137                    bad[i] = row + ['invalid date']
3138                if value == 0:
3139                    bad[i] = row + ['invalid value']
3140                    continue
3141                if date not in data:
3142                    data[date] = []
3143                data[date].append((i, account, desc, value, date, rate, hashed))
3144
3145        if debug:
3146            print('import_csv', len(data))
3147
3148        if bad:
3149            return created, found, bad
3150
3151        no_lock = self.nolock()
3152        lock = self.__lock()
3153        for date, rows in sorted(data.items()):
3154            try:
3155                len_rows = len(rows)
3156                if len_rows == 1:
3157                    (_, account, desc, unscaled_value, date, rate, hashed) = rows[0]
3158                    value = self.unscale(
3159                        unscaled_value,
3160                        decimal_places=scale_decimal_places,
3161                    ) if scale_decimal_places > 0 else unscaled_value
3162                    if rate > 0:
3163                        self.exchange(account=account, created_time_ns=date, rate=rate)
3164                    if value > 0:
3165                        self.track(unscaled_value=value, desc=desc, account=account, created_time_ns=date)
3166                    elif value < 0:
3167                        self.subtract(unscaled_value=-value, desc=desc, account=account, created_time_ns=date)
3168                    created += 1
3169                    cache.append(hashed)
3170                    continue
3171                if debug:
3172                    print('-- Duplicated time detected', date, 'len', len_rows)
3173                    print(rows)
3174                    print('---------------------------------')
3175                # If records are found at the same time with different accounts in the same amount
3176                # (one positive and the other negative), this indicates it is a transfer.
3177                if len_rows != 2:
3178                    raise Exception(f'more than two transactions({len_rows}) at the same time')
3179                (i, account1, desc1, unscaled_value1, date1, rate1, _) = rows[0]
3180                (j, account2, desc2, unscaled_value2, date2, rate2, _) = rows[1]
3181                if account1 == account2 or desc1 != desc2 or abs(unscaled_value1) != abs(
3182                        unscaled_value2) or date1 != date2:
3183                    raise Exception('invalid transfer')
3184                if rate1 > 0:
3185                    self.exchange(account1, created_time_ns=date1, rate=rate1)
3186                if rate2 > 0:
3187                    self.exchange(account2, created_time_ns=date2, rate=rate2)
3188                value1 = self.unscale(
3189                    unscaled_value1,
3190                    decimal_places=scale_decimal_places,
3191                ) if scale_decimal_places > 0 else unscaled_value1
3192                value2 = self.unscale(
3193                    unscaled_value2,
3194                    decimal_places=scale_decimal_places,
3195                ) if scale_decimal_places > 0 else unscaled_value2
3196                values = {
3197                    value1: account1,
3198                    value2: account2,
3199                }
3200                self.transfer(
3201                    unscaled_amount=abs(value1),
3202                    from_account=values[min(values.keys())],
3203                    to_account=values[max(values.keys())],
3204                    desc=desc1,
3205                    created_time_ns=date1,
3206                )
3207            except Exception as e:
3208                for (i, account, desc, value, date, rate, _) in rows:
3209                    bad[i] = (account, desc, value, date, rate, e)
3210                break
3211        with open(self.import_csv_cache_path(), 'w', encoding='utf-8') as stream:
3212            stream.write(json.dumps(cache))
3213        if no_lock:
3214            assert lock is not None
3215            self.free(lock)
3216        y = created, found, bad
3217        if debug:
3218            debug_path = f'{self.import_csv_cache_path()}.debug.json'
3219            with open(debug_path, 'w', encoding='utf-8') as file:
3220                json.dump(y, file, indent=4, cls=JSONEncoder)
3221                print(f'generated debug report @ `{debug_path}`...')
3222        return y
3223
3224    ########
3225    # TESTS #
3226    #######
3227
3228    @staticmethod
3229    def human_readable_size(size: float, decimal_places: int = 2) -> str:
3230        """
3231        Converts a size in bytes to a human-readable format (e.g., KB, MB, GB).
3232
3233        This function iterates through progressively larger units of information
3234        (B, KB, MB, GB, etc.) and divides the input size until it fits within a
3235        range that can be expressed with a reasonable number before the unit.
3236
3237        Parameters:
3238        - size (float): The size in bytes to convert.
3239        - decimal_places (int, optional): The number of decimal places to display
3240            in the result. Defaults to 2.
3241
3242        Returns:
3243        - str: A string representation of the size in a human-readable format,
3244            rounded to the specified number of decimal places. For example:
3245                - '1.50 KB' (1536 bytes)
3246                - '23.00 MB' (24117248 bytes)
3247                - '1.23 GB' (1325899906 bytes)
3248        """
3249        if type(size) not in (float, int):
3250            raise TypeError('size must be a float or integer')
3251        if type(decimal_places) != int:
3252            raise TypeError('decimal_places must be an integer')
3253        for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']:
3254            if size < 1024.0:
3255                break
3256            size /= 1024.0
3257        return f'{size:.{decimal_places}f} {unit}'
3258
3259    @staticmethod
3260    def get_dict_size(obj: dict, seen: Optional[set] = None) -> float:
3261        """
3262        Recursively calculates the approximate memory size of a dictionary and its contents in bytes.
3263
3264        This function traverses the dictionary structure, accounting for the size of keys, values,
3265        and any nested objects. It handles various data types commonly found in dictionaries
3266        (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case
3267        of circular references.
3268
3269        Parameters:
3270        - obj (dict): The dictionary whose size is to be calculated.
3271        - seen (set, optional): A set used internally to track visited objects
3272                             and avoid circular references. Defaults to None.
3273
3274        Returns:
3275         - float: An approximate size of the dictionary and its contents in bytes.
3276
3277        Notes:
3278        - This function is a method of the `ZakatTracker` class and is likely used to
3279          estimate the memory footprint of data structures relevant to Zakat calculations.
3280        - The size calculation is approximate as it relies on `sys.getsizeof()`, which might
3281          not account for all memory overhead depending on the Python implementation.
3282        - Circular references are handled to prevent infinite recursion.
3283        - Basic numeric types (int, float, complex) are assumed to have fixed sizes.
3284        - String sizes are estimated based on character length and encoding.
3285        """
3286        size = 0
3287        if seen is None:
3288            seen = set()
3289
3290        obj_id = id(obj)
3291        if obj_id in seen:
3292            return 0
3293
3294        seen.add(obj_id)
3295        size += sys.getsizeof(obj)
3296
3297        if isinstance(obj, dict):
3298            for k, v in obj.items():
3299                size += ZakatTracker.get_dict_size(k, seen)
3300                size += ZakatTracker.get_dict_size(v, seen)
3301        elif isinstance(obj, (list, tuple, set, frozenset)):
3302            for item in obj:
3303                size += ZakatTracker.get_dict_size(item, seen)
3304        elif isinstance(obj, (int, float, complex)):  # Handle numbers
3305            pass  # Basic numbers have a fixed size, so nothing to add here
3306        elif isinstance(obj, str):  # Handle strings
3307            size += len(obj) * sys.getsizeof(str().encode())  # Size per character in bytes
3308        return size
3309
3310    @staticmethod
3311    def day_to_time(day: int, month: int = 6, year: int = 2024) -> int:  # افتراض أن الشهر هو يونيو والسنة 2024
3312        """
3313        Convert a specific day, month, and year into a timestamp.
3314
3315        Parameters:
3316        - day (int): The day of the month.
3317        - month (int, optional): The month of the year. Default is 6 (June).
3318        - year (int, optional): The year. Default is 2024.
3319
3320        Returns:
3321        - int: The timestamp representing the given day, month, and year.
3322
3323        Note:
3324        - This method assumes the default month and year if not provided.
3325        """
3326        return Time.time(datetime.datetime(year, month, day))
3327
3328    @staticmethod
3329    def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
3330        """
3331        Generate a random date between two given dates.
3332
3333        Parameters:
3334        - start_date (datetime.datetime): The start date from which to generate a random date.
3335        - end_date (datetime.datetime): The end date until which to generate a random date.
3336
3337        Returns:
3338        - datetime.datetime: A random date between the start_date and end_date.
3339        """
3340        time_between_dates = end_date - start_date
3341        days_between_dates = time_between_dates.days
3342        random_number_of_days = random.randrange(days_between_dates)
3343        return start_date + datetime.timedelta(days=random_number_of_days)
3344
3345    @staticmethod
3346    def generate_random_csv_file(path: str = 'data.csv', count: int = 1_000, with_rate: bool = False,
3347                                 debug: bool = False) -> int:
3348        """
3349        Generate a random CSV file with specified parameters.
3350        The function generates a CSV file at the specified path with the given count of rows.
3351        Each row contains a randomly generated account, description, value, and date.
3352        The value is randomly generated between 1000 and 100000,
3353        and the date is randomly generated between 1950-01-01 and 2023-12-31.
3354        If the row number is not divisible by 13, the value is multiplied by -1.
3355
3356        Parameters:
3357        - path (str, optional): The path where the CSV file will be saved. Default is 'data.csv'.
3358        - count (int, optional): The number of rows to generate in the CSV file. Default is 1000.
3359        - with_rate (bool, optional): If True, a random rate between 1.2% and 12% is added. Default is False.
3360        - debug (bool, optional): A flag indicating whether to print debug information.
3361
3362        Returns:
3363        None
3364        """
3365        if debug:
3366            print('generate_random_csv_file', f'debug={debug}')
3367        i = 0
3368        with open(path, 'w', newline='', encoding='utf-8') as csvfile:
3369            writer = csv.writer(csvfile)
3370            for i in range(count):
3371                account = f'acc-{random.randint(1, count)}'
3372                desc = f'Some text {random.randint(1, count)}'
3373                value = random.randint(1000, 100000)
3374                date = ZakatTracker.generate_random_date(datetime.datetime(1000, 1, 1),
3375                                                         datetime.datetime(2023, 12, 31)).strftime('%Y-%m-%d %H:%M:%S')
3376                if not i % 13 == 0:
3377                    value *= -1
3378                row = [account, desc, value, date]
3379                if with_rate:
3380                    rate = random.randint(1, 100) * 0.12
3381                    if debug:
3382                        print('before-append', row)
3383                    row.append(rate)
3384                    if debug:
3385                        print('after-append', row)
3386                writer.writerow(row)
3387                i = i + 1
3388        return i
3389
3390    @staticmethod
3391    def create_random_list(max_sum: int, min_value: int = 0, max_value: int = 10):
3392        """
3393        Creates a list of random integers whose sum does not exceed the specified maximum.
3394
3395        Parameters:
3396        - max_sum (int): The maximum allowed sum of the list elements.
3397        - min_value (int, optional): The minimum possible value for an element (inclusive).
3398        - max_value (int, optional): The maximum possible value for an element (inclusive).
3399
3400        Returns:
3401        - A list of random integers.
3402        """
3403        result = []
3404        current_sum = 0
3405
3406        while current_sum < max_sum:
3407            # Calculate the remaining space for the next element
3408            remaining_sum = max_sum - current_sum
3409            # Determine the maximum possible value for the next element
3410            next_max_value = min(remaining_sum, max_value)
3411            # Generate a random element within the allowed range
3412            next_element = random.randint(min_value, next_max_value)
3413            result.append(next_element)
3414            current_sum += next_element
3415
3416        return result
3417
3418    def _test_core(self, restore: bool = False, debug: bool = False):
3419
3420        if debug:
3421            random.seed(1234567890)
3422
3423        Time.test(debug)
3424
3425        # sanity check - random forward time
3426
3427        xlist = []
3428        limit = 1000
3429        for _ in range(limit):
3430            y = Time.time()
3431            z = '-'
3432            if y not in xlist:
3433                xlist.append(y)
3434            else:
3435                z = 'x'
3436            if debug:
3437                print(z, y)
3438        xx = len(xlist)
3439        if debug:
3440            print('count', xx, ' - unique: ', (xx / limit) * 100, '%')
3441        assert limit == xx
3442
3443        # test ZakatTracker.split_at_last_symbol
3444
3445        test_cases = [
3446            ("This is a string @ with a symbol.", '@', ("This is a string ", " with a symbol.")),
3447            ("No symbol here.", '$', ("No symbol here.", "")),
3448            ("Multiple $ symbols $ in the string.", '$', ("Multiple $ symbols ", " in the string.")),
3449            ("Here is a symbol%", '%', ("Here is a symbol", "")),
3450            ("@only a symbol", '@', ("", "only a symbol")),
3451            ("", '#', ("", "")),
3452            ("test/test/test.txt", '/', ("test/test", "test.txt")),
3453            ("abc#def#ghi", "#", ("abc#def", "ghi")),
3454            ("abc", "#", ("abc", "")),
3455            ("//https://test", '//', ("//https:", "test")),
3456        ]
3457        
3458        for data, symbol, expected in test_cases:
3459            result = ZakatTracker.split_at_last_symbol(data, symbol)
3460            assert result == expected, f"Test failed for data='{data}', symbol='{symbol}'. Expected {expected}, got {result}"
3461
3462        # human_readable_size
3463
3464        assert ZakatTracker.human_readable_size(0) == '0.00 B'
3465        assert ZakatTracker.human_readable_size(512) == '512.00 B'
3466        assert ZakatTracker.human_readable_size(1023) == '1023.00 B'
3467
3468        assert ZakatTracker.human_readable_size(1024) == '1.00 KB'
3469        assert ZakatTracker.human_readable_size(2048) == '2.00 KB'
3470        assert ZakatTracker.human_readable_size(5120) == '5.00 KB'
3471
3472        assert ZakatTracker.human_readable_size(1024 ** 2) == '1.00 MB'
3473        assert ZakatTracker.human_readable_size(2.5 * 1024 ** 2) == '2.50 MB'
3474
3475        assert ZakatTracker.human_readable_size(1024 ** 3) == '1.00 GB'
3476        assert ZakatTracker.human_readable_size(1024 ** 4) == '1.00 TB'
3477        assert ZakatTracker.human_readable_size(1024 ** 5) == '1.00 PB'
3478
3479        assert ZakatTracker.human_readable_size(1536, decimal_places=0) == '2 KB'
3480        assert ZakatTracker.human_readable_size(2.5 * 1024 ** 2, decimal_places=1) == '2.5 MB'
3481        assert ZakatTracker.human_readable_size(1234567890, decimal_places=3) == '1.150 GB'
3482
3483        try:
3484            # noinspection PyTypeChecker
3485            ZakatTracker.human_readable_size('not a number')
3486            assert False, 'Expected TypeError for invalid input'
3487        except TypeError:
3488            pass
3489
3490        try:
3491            # noinspection PyTypeChecker
3492            ZakatTracker.human_readable_size(1024, decimal_places='not an int')
3493            assert False, 'Expected TypeError for invalid decimal_places'
3494        except TypeError:
3495            pass
3496
3497        # get_dict_size
3498        assert ZakatTracker.get_dict_size({}) == sys.getsizeof({}), 'Empty dictionary size mismatch'
3499        assert ZakatTracker.get_dict_size({'a': 1, 'b': 2.5, 'c': True}) != sys.getsizeof({}), 'Not Empty dictionary'
3500
3501        # number scale
3502        error = 0
3503        total = 0
3504        for sign in ['', '-']:
3505            for max_i, max_j, decimal_places in [
3506                (101, 101, 2),  # fiat currency minimum unit took 2 decimal places
3507                (1, 1_000, 8),  # cryptocurrency like Satoshi in Bitcoin took 8 decimal places
3508                (1, 1_000, 18)  # cryptocurrency like Wei in Ethereum took 18 decimal places
3509            ]:
3510                for return_type in (
3511                        float,
3512                        decimal.Decimal,
3513                ):
3514                    for i in range(max_i):
3515                        for j in range(max_j):
3516                            total += 1
3517                            num_str = f'{sign}{i}.{j:0{decimal_places}d}'
3518                            num = return_type(num_str)
3519                            scaled = self.scale(num, decimal_places=decimal_places)
3520                            unscaled = self.unscale(scaled, return_type=return_type, decimal_places=decimal_places)
3521                            if debug:
3522                                print(
3523                                    f'return_type: {return_type}, num_str: {num_str} - num: {num} - scaled: {scaled} - unscaled: {unscaled}')
3524                            if unscaled != num:
3525                                if debug:
3526                                    print('***** SCALE ERROR *****')
3527                                error += 1
3528        if debug:
3529            print(f'total: {total}, error({error}): {100 * error / total}%')
3530        assert error == 0
3531
3532        # test lock
3533
3534        assert self.nolock()
3535        assert self.__history() is True
3536        lock = self.lock()
3537        assert lock is not None
3538        assert lock > 0
3539        failed = False
3540        try:
3541            self.lock()
3542        except:
3543            failed = True
3544        assert failed
3545        assert self.free(lock)
3546        assert not self.free(lock)
3547
3548        table = {
3549            AccountName('1'): [
3550                (0, 10, 1000, 1000, 1000, 1, 1),
3551                (0, 20, 3000, 3000, 3000, 2, 2),
3552                (0, 30, 6000, 6000, 6000, 3, 3),
3553                (1, 15, 4500, 4500, 4500, 3, 4),
3554                (1, 50, -500, -500, -500, 4, 5),
3555                (1, 100, -10500, -10500, -10500, 5, 6),
3556            ],
3557            AccountName('wallet'): [
3558                (1, 90, -9000, -9000, -9000, 1, 1),
3559                (0, 100, 1000, 1000, 1000, 2, 2),
3560                (1, 190, -18000, -18000, -18000, 3, 3),
3561                (0, 1000, 82000, 82000, 82000, 4, 4),
3562            ],
3563        }
3564        for x in table:
3565            for y in table[x]:
3566                lock = self.lock()
3567                if y[0] == 0:
3568                    ref = self.track(
3569                        unscaled_value=y[1],
3570                        desc='test-add',
3571                        account=x,
3572                        created_time_ns=Time.time(),
3573                        debug=debug,
3574                    )
3575                else:
3576                    report = self.subtract(
3577                        unscaled_value=y[1],
3578                        desc='test-sub',
3579                        account=x,
3580                        created_time_ns=Time.time(),
3581                    )
3582                    ref = report.log_ref
3583                    if debug:
3584                        print('_sub', z, Time.time())
3585                assert ref != 0
3586                assert len(self.__vault.account[x].log[ref].file) == 0
3587                for i in range(3):
3588                    file_ref = self.add_file(x, ref, 'file_' + str(i))
3589                    time.sleep(0.0000001)
3590                    assert file_ref != 0
3591                    if debug:
3592                        print('ref', ref, 'file', file_ref)
3593                    assert len(self.__vault.account[x].log[ref].file) == i + 1
3594                file_ref = self.add_file(x, ref, 'file_' + str(3))
3595                assert self.remove_file(x, ref, file_ref)
3596                daily_logs = self.daily_logs(debug=debug)
3597                if debug:
3598                    print('daily_logs', daily_logs)
3599                for k, v in daily_logs.items():
3600                    assert k
3601                    assert v
3602                z = self.balance(x)
3603                if debug:
3604                    print('debug-0', z, y)
3605                assert z == y[2]
3606                z = self.balance(x, False)
3607                if debug:
3608                    print('debug-1', z, y[3])
3609                assert z == y[3]
3610                o = self.__vault.account[x].log
3611                z = 0
3612                for i in o:
3613                    z += o[i].value
3614                if debug:
3615                    print('debug-2', z, type(z))
3616                    print('debug-2', y[4], type(y[4]))
3617                assert z == y[4]
3618                if debug:
3619                    print('debug-2 - PASSED')
3620                assert self.box_size(x) == y[5]
3621                assert self.log_size(x) == y[6]
3622                assert not self.nolock()
3623                assert lock is not None
3624                self.free(lock)
3625                assert self.nolock()
3626            assert self.boxes(x) != {}
3627            assert self.logs(x) != {}
3628
3629            assert not self.hide(x)
3630            assert self.hide(x, False) is False
3631            assert self.hide(x) is False
3632            assert self.hide(x, True)
3633            assert self.hide(x)
3634
3635            assert self.zakatable(x)
3636            assert self.zakatable(x, False) is False
3637            assert self.zakatable(x) is False
3638            assert self.zakatable(x, True)
3639            assert self.zakatable(x)
3640
3641        if restore is True:
3642            # invalid restore point
3643            for lock in [0, time.time_ns(), Time.time()]:
3644                failed = False
3645                try:
3646                    self.recall(True, lock)
3647                except:
3648                    failed = True
3649                assert failed
3650            count = len(self.__vault.history)
3651            if debug:
3652                print('history-count', count)
3653            assert count == 10
3654            # try mode
3655            for _ in range(count):
3656                assert self.recall(True, debug=debug)
3657            count = len(self.__vault.history)
3658            if debug:
3659                print('history-count', count)
3660            assert count == 10
3661            _accounts = list(table.keys())
3662            accounts_limit = len(_accounts) + 1
3663            for i in range(-1, -accounts_limit, -1):
3664                account = _accounts[i]
3665                if debug:
3666                    print(account, len(table[account]))
3667                transaction_limit = len(table[account]) + 1
3668                for j in range(-1, -transaction_limit, -1):
3669                    row = table[account][j]
3670                    if debug:
3671                        print(row, self.balance(account), self.balance(account, False))
3672                    assert self.balance(account) == self.balance(account, False)
3673                    assert self.balance(account) == row[2]
3674                    assert self.recall(False, debug=debug)
3675            assert self.recall(False, debug=debug) is False
3676            count = len(self.__vault.history)
3677            if debug:
3678                print('history-count', count)
3679            assert count == 0
3680            self.reset()
3681
3682    def test(self, debug: bool = False) -> bool:
3683        if debug:
3684            print('test', f'debug={debug}')
3685        try:
3686
3687            self._test_core(True, debug)
3688            self._test_core(False, debug)
3689
3690            assert self.__history()
3691
3692            # Not allowed for duplicate transactions in the same account and time
3693
3694            created = Time.time()
3695            self.track(100, 'test-1', 'same', True, created)
3696            failed = False
3697            try:
3698                self.track(50, 'test-1', 'same', True, created)
3699            except:
3700                failed = True
3701            assert failed is True
3702
3703            self.reset()
3704
3705            # Same account transfer
3706            for x in [1, 'a', True, 1.8, None]:
3707                failed = False
3708                try:
3709                    self.transfer(1, x, x, 'same-account', debug=debug)
3710                except:
3711                    failed = True
3712                assert failed is True
3713
3714            # Always preserve box age during transfer
3715
3716            series: list[tuple[int, int]] = [
3717                (30, 4),
3718                (60, 3),
3719                (90, 2),
3720            ]
3721            case = {
3722                3000: {
3723                    'series': series,
3724                    'rest': 15000,
3725                },
3726                6000: {
3727                    'series': series,
3728                    'rest': 12000,
3729                },
3730                9000: {
3731                    'series': series,
3732                    'rest': 9000,
3733                },
3734                18000: {
3735                    'series': series,
3736                    'rest': 0,
3737                },
3738                27000: {
3739                    'series': series,
3740                    'rest': -9000,
3741                },
3742                36000: {
3743                    'series': series,
3744                    'rest': -18000,
3745                },
3746            }
3747
3748            selected_time = Time.time() - ZakatTracker.TimeCycle()
3749
3750            for total in case:
3751                if debug:
3752                    print('--------------------------------------------------------')
3753                    print(f'case[{total}]', case[total])
3754                for x in case[total]['series']:
3755                    self.track(
3756                        unscaled_value=x[0],
3757                        desc=f'test-{x} ages',
3758                        account=AccountName('ages'),
3759                        created_time_ns=selected_time * x[1],
3760                    )
3761
3762                unscaled_total = self.unscale(total)
3763                if debug:
3764                    print('unscaled_total', unscaled_total)
3765                refs = self.transfer(
3766                    unscaled_amount=unscaled_total,
3767                    from_account='ages',
3768                    to_account='future',
3769                    desc='Zakat Movement',
3770                    debug=debug,
3771                )
3772
3773                if debug:
3774                    print('refs', refs)
3775
3776                ages_cache_balance = self.balance('ages')
3777                ages_fresh_balance = self.balance('ages', False)
3778                rest = case[total]['rest']
3779                if debug:
3780                    print('source', ages_cache_balance, ages_fresh_balance, rest)
3781                assert ages_cache_balance == rest
3782                assert ages_fresh_balance == rest
3783
3784                future_cache_balance = self.balance('future')
3785                future_fresh_balance = self.balance('future', False)
3786                if debug:
3787                    print('target', future_cache_balance, future_fresh_balance, total)
3788                    print('refs', refs)
3789                assert future_cache_balance == total
3790                assert future_fresh_balance == total
3791
3792                # TODO: check boxes times for `ages` should equal box times in `future`
3793                for ref in self.__vault.account['ages'].box:
3794                    ages_capital = self.__vault.account['ages'].box[ref].capital
3795                    ages_rest = self.__vault.account['ages'].box[ref].rest
3796                    future_capital = 0
3797                    if ref in self.__vault.account['future'].box:
3798                        future_capital = self.__vault.account['future'].box[ref].capital
3799                    future_rest = 0
3800                    if ref in self.__vault.account['future'].box:
3801                        future_rest = self.__vault.account['future'].box[ref].rest
3802                    if ages_capital != 0 and future_capital != 0 and future_rest != 0:
3803                        if debug:
3804                            print('================================================================')
3805                            print('ages', ages_capital, ages_rest)
3806                            print('future', future_capital, future_rest)
3807                        if ages_rest == 0:
3808                            assert ages_capital == future_capital
3809                        elif ages_rest < 0:
3810                            assert -ages_capital == future_capital
3811                        elif ages_rest > 0:
3812                            assert ages_capital == ages_rest + future_capital
3813                self.reset()
3814                assert len(self.__vault.history) == 0
3815
3816            assert self.__history()
3817            assert self.__history(False) is False
3818            assert self.__history() is False
3819            assert self.__history(True)
3820            assert self.__history()
3821            if debug:
3822                print('####################################################################')
3823
3824            transaction = [
3825                (
3826                    20, 'wallet', 1, -2000, -2000, -2000, 1, 1,
3827                    2000, 2000, 2000, 1, 1,
3828                ),
3829                (
3830                    750, 'wallet', 'safe', -77000, -77000, -77000, 2, 2,
3831                    75000, 75000, 75000, 1, 1,
3832                ),
3833                (
3834                    600, 'safe', 'bank', 15000, 15000, 15000, 1, 2,
3835                    60000, 60000, 60000, 1, 1,
3836                ),
3837            ]
3838            for z in transaction:
3839                lock = self.lock()
3840                x = z[1]
3841                y = z[2]
3842                self.transfer(
3843                    unscaled_amount=z[0],
3844                    from_account=x,
3845                    to_account=y,
3846                    desc='test-transfer',
3847                    debug=debug,
3848                )
3849                zz = self.balance(x)
3850                if debug:
3851                    print(zz, z)
3852                assert zz == z[3]
3853                xx = self.accounts()[x]
3854                assert xx == z[3]
3855                assert self.balance(x, False) == z[4]
3856                assert xx == z[4]
3857
3858                s = 0
3859                log = self.__vault.account[x].log
3860                for i in log:
3861                    s += log[i].value
3862                if debug:
3863                    print('s', s, 'z[5]', z[5])
3864                assert s == z[5]
3865
3866                assert self.box_size(x) == z[6]
3867                assert self.log_size(x) == z[7]
3868
3869                yy = self.accounts()[y]
3870                assert self.balance(y) == z[8]
3871                assert yy == z[8]
3872                assert self.balance(y, False) == z[9]
3873                assert yy == z[9]
3874
3875                s = 0
3876                log = self.__vault.account[y].log
3877                for i in log:
3878                    s += log[i].value
3879                assert s == z[10]
3880
3881                assert self.box_size(y) == z[11]
3882                assert self.log_size(y) == z[12]
3883                assert lock is not None
3884                assert self.free(lock)
3885
3886            if debug:
3887                pp().pprint(self.check(2.17))
3888
3889            assert self.nolock()
3890            history_count = len(self.__vault.history)
3891            if debug:
3892                print('history-count', history_count)
3893            transaction_count = len(transaction)
3894            assert history_count == transaction_count
3895            assert not self.free(Time.time())
3896            assert self.free(self.lock())
3897            assert self.nolock()
3898            assert len(self.__vault.history) == transaction_count
3899
3900            # recall
3901
3902            assert self.nolock()
3903            assert len(self.__vault.history) == 3
3904            assert self.recall(False, debug=debug) is True
3905            assert len(self.__vault.history) == 2
3906            assert self.recall(False, debug=debug) is True
3907            assert len(self.__vault.history) == 1
3908            assert self.recall(False, debug=debug) is True
3909            assert len(self.__vault.history) == 0
3910            assert self.recall(False, debug=debug) is False
3911            assert len(self.__vault.history) == 0
3912
3913            # exchange
3914
3915            self.exchange('cash', 25, 3.75, '2024-06-25')
3916            self.exchange('cash', 22, 3.73, '2024-06-22')
3917            self.exchange('cash', 15, 3.69, '2024-06-15')
3918            self.exchange('cash', 10, 3.66)
3919
3920            assert self.nolock()
3921
3922            for i in range(1, 30):
3923                exchange = self.exchange('cash', i)
3924                rate, description, created = exchange.rate, exchange.description, exchange.time
3925                if debug:
3926                    print(i, rate, description, created)
3927                assert created
3928                if i < 10:
3929                    assert rate == 1
3930                    assert description is None
3931                elif i == 10:
3932                    assert rate == 3.66
3933                    assert description is None
3934                elif i < 15:
3935                    assert rate == 3.66
3936                    assert description is None
3937                elif i == 15:
3938                    assert rate == 3.69
3939                    assert description is not None
3940                elif i < 22:
3941                    assert rate == 3.69
3942                    assert description is not None
3943                elif i == 22:
3944                    assert rate == 3.73
3945                    assert description is not None
3946                elif i >= 25:
3947                    assert rate == 3.75
3948                    assert description is not None
3949                exchange = self.exchange('bank', i)
3950                rate, description, created = exchange.rate, exchange.description, exchange.time
3951                if debug:
3952                    print(i, rate, description, created)
3953                assert created
3954                assert rate == 1
3955                assert description is None
3956
3957            assert len(self.__vault.exchange) == 1
3958            assert len(self.exchanges()) == 1
3959            self.__vault.exchange.clear()
3960            assert len(self.__vault.exchange) == 0
3961            assert len(self.exchanges()) == 0
3962            self.reset()
3963
3964            # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية
3965            self.exchange('cash', ZakatTracker.day_to_time(25), 3.75, '2024-06-25')
3966            self.exchange('cash', ZakatTracker.day_to_time(22), 3.73, '2024-06-22')
3967            self.exchange('cash', ZakatTracker.day_to_time(15), 3.69, '2024-06-15')
3968            self.exchange('cash', ZakatTracker.day_to_time(10), 3.66)
3969
3970            assert self.nolock()
3971
3972            for i in [x * 0.12 for x in range(-15, 21)]:
3973                if i <= 0:
3974                    assert self.exchange('test', Time.time(), i, f'range({i})') == Exchange()
3975                else:
3976                    assert self.exchange('test', Time.time(), i, f'range({i})') is not Exchange()
3977
3978            assert self.nolock()
3979
3980           # اختبار النتائج باستخدام التواريخ بالنانو ثانية
3981            for i in range(1, 31):
3982                timestamp_ns = ZakatTracker.day_to_time(i)
3983                exchange = self.exchange('cash', timestamp_ns)
3984                rate, description, created = exchange.rate, exchange.description, exchange.time
3985                if debug:
3986                    print(i, rate, description, created)
3987                assert created
3988                if i < 10:
3989                    assert rate == 1
3990                    assert description is None
3991                elif i == 10:
3992                    assert rate == 3.66
3993                    assert description is None
3994                elif i < 15:
3995                    assert rate == 3.66
3996                    assert description is None
3997                elif i == 15:
3998                    assert rate == 3.69
3999                    assert description is not None
4000                elif i < 22:
4001                    assert rate == 3.69
4002                    assert description is not None
4003                elif i == 22:
4004                    assert rate == 3.73
4005                    assert description is not None
4006                elif i >= 25:
4007                    assert rate == 3.75
4008                    assert description is not None
4009                exchange = self.exchange('bank', i)
4010                rate, description, created = exchange.rate, exchange.description, exchange.time
4011                if debug:
4012                    print(i, rate, description, created)
4013                assert created
4014                assert rate == 1
4015                assert description is None
4016
4017            assert self.nolock()
4018
4019            self.reset()
4020
4021            # test transfer between accounts with different exchange rate
4022
4023            a_SAR = 'Bank (SAR)'
4024            b_USD = 'Bank (USD)'
4025            c_SAR = 'Safe (SAR)'
4026            # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer
4027            for case in [
4028                (0, a_SAR, 'SAR Gift', 1000, 100000),
4029                (1, a_SAR, 1),
4030                (0, b_USD, 'USD Gift', 500, 50000),
4031                (1, b_USD, 1),
4032                (2, b_USD, 3.75),
4033                (1, b_USD, 3.75),
4034                (3, 100, b_USD, a_SAR, '100 USD -> SAR', 40000, 137500),
4035                (0, c_SAR, 'Salary', 750, 75000),
4036                (3, 375, c_SAR, b_USD, '375 SAR -> USD', 37500, 50000),
4037                (3, 3.75, a_SAR, b_USD, '3.75 SAR -> USD', 137125, 50100),
4038            ]:
4039                if debug:
4040                    print('case', case)
4041                match (case[0]):
4042                    case 0:  # track
4043                        _, account, desc, x, balance = case
4044                        self.track(unscaled_value=x, desc=desc, account=account, debug=debug)
4045
4046                        cached_value = self.balance(account, cached=True)
4047                        fresh_value = self.balance(account, cached=False)
4048                        if debug:
4049                            print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value)
4050                        assert cached_value == balance
4051                        assert fresh_value == balance
4052                    case 1:  # check-exchange
4053                        _, account, expected_rate = case
4054                        t_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug)
4055                        if debug:
4056                            print('t-exchange', t_exchange)
4057                        assert t_exchange.rate == expected_rate
4058                    case 2:  # do-exchange
4059                        _, account, rate = case
4060                        self.exchange(account, rate=rate, debug=debug)
4061                        b_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug)
4062                        if debug:
4063                            print('b-exchange', b_exchange)
4064                        assert b_exchange.rate == rate
4065                    case 3:  # transfer
4066                        _, x, a, b, desc, a_balance, b_balance = case
4067                        self.transfer(x, a, b, desc, debug=debug)
4068
4069                        cached_value = self.balance(a, cached=True)
4070                        fresh_value = self.balance(a, cached=False)
4071                        if debug:
4072                            print(
4073                                'account', a,
4074                                'cached_value', cached_value,
4075                                'fresh_value', fresh_value,
4076                                'a_balance', a_balance,
4077                            )
4078                        assert cached_value == a_balance
4079                        assert fresh_value == a_balance
4080
4081                        cached_value = self.balance(b, cached=True)
4082                        fresh_value = self.balance(b, cached=False)
4083                        if debug:
4084                            print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value)
4085                        assert cached_value == b_balance
4086                        assert fresh_value == b_balance
4087
4088            # Transfer all in many chunks randomly from B to A
4089            a_SAR_balance = 137125
4090            b_USD_balance = 50100
4091            b_USD_exchange = self.exchange(b_USD)
4092            amounts = ZakatTracker.create_random_list(b_USD_balance, max_value=1000)
4093            if debug:
4094                print('amounts', amounts)
4095            i = 0
4096            for x in amounts:
4097                if debug:
4098                    print(f'{i} - transfer-with-exchange({x})')
4099                self.transfer(
4100                    unscaled_amount=self.unscale(x),
4101                    from_account=b_USD,
4102                    to_account=a_SAR,
4103                    desc=f'{x} USD -> SAR',
4104                    debug=debug,
4105                )
4106
4107                b_USD_balance -= x
4108                cached_value = self.balance(b_USD, cached=True)
4109                fresh_value = self.balance(b_USD, cached=False)
4110                if debug:
4111                    print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
4112                          b_USD_balance)
4113                assert cached_value == b_USD_balance
4114                assert fresh_value == b_USD_balance
4115
4116                a_SAR_balance += int(x * b_USD_exchange.rate)
4117                cached_value = self.balance(a_SAR, cached=True)
4118                fresh_value = self.balance(a_SAR, cached=False)
4119                if debug:
4120                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
4121                          a_SAR_balance, 'rate', b_USD_exchange.rate)
4122                assert cached_value == a_SAR_balance
4123                assert fresh_value == a_SAR_balance
4124                i += 1
4125
4126            # Transfer all in many chunks randomly from C to A
4127            c_SAR_balance = 37500
4128            amounts = ZakatTracker.create_random_list(c_SAR_balance, max_value=1000)
4129            if debug:
4130                print('amounts', amounts)
4131            i = 0
4132            for x in amounts:
4133                if debug:
4134                    print(f'{i} - transfer-with-exchange({x})')
4135                self.transfer(
4136                    unscaled_amount=self.unscale(x),
4137                    from_account=c_SAR,
4138                    to_account=a_SAR,
4139                    desc=f'{x} SAR -> a_SAR',
4140                    debug=debug,
4141                )
4142
4143                c_SAR_balance -= x
4144                cached_value = self.balance(c_SAR, cached=True)
4145                fresh_value = self.balance(c_SAR, cached=False)
4146                if debug:
4147                    print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
4148                          c_SAR_balance)
4149                assert cached_value == c_SAR_balance
4150                assert fresh_value == c_SAR_balance
4151
4152                a_SAR_balance += x
4153                cached_value = self.balance(a_SAR, cached=True)
4154                fresh_value = self.balance(a_SAR, cached=False)
4155                if debug:
4156                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
4157                          a_SAR_balance)
4158                assert cached_value == a_SAR_balance
4159                assert fresh_value == a_SAR_balance
4160                i += 1
4161
4162            assert self.save(f'accounts-transfer-with-exchange-rates.{self.ext()}')
4163
4164            # check & zakat with exchange rates for many cycles
4165
4166            lock = None
4167            for rate, values in {
4168                1: {
4169                    'in': [1000, 2000, 10000],
4170                    'exchanged': [100000, 200000, 1000000],
4171                    'out': [2500, 5000, 73140],
4172                },
4173                3.75: {
4174                    'in': [200, 1000, 5000],
4175                    'exchanged': [75000, 375000, 1875000],
4176                    'out': [1875, 9375, 137138],
4177                },
4178            }.items():
4179                a, b, c = values['in']
4180                m, n, o = values['exchanged']
4181                x, y, z = values['out']
4182                if debug:
4183                    print('rate', rate, 'values', values)
4184                for case in [
4185                    (a, 'safe', Time.time() - ZakatTracker.TimeCycle(), [
4186                        {'safe': {0: {'below_nisab': x}}},
4187                    ], False, m),
4188                    (b, 'safe', Time.time() - ZakatTracker.TimeCycle(), [
4189                        {'safe': {0: {'count': 1, 'total': y}}},
4190                    ], True, n),
4191                    (c, 'cave', Time.time() - (ZakatTracker.TimeCycle() * 3), [
4192                        {'cave': {0: {'count': 3, 'total': z}}},
4193                    ], True, o),
4194                ]:
4195                    if debug:
4196                        print(f'############# check(rate: {rate}) #############')
4197                        print('case', case)
4198                    self.reset()
4199                    self.exchange(account=case[1], created_time_ns=case[2], rate=rate)
4200                    self.track(
4201                        unscaled_value=case[0],
4202                        desc='test-check',
4203                        account=case[1],
4204                        created_time_ns=case[2],
4205                    )
4206                    assert self.snapshot()
4207
4208                    # assert self.nolock()
4209                    # history_size = len(self.__vault.history)
4210                    # print('history_size', history_size)
4211                    # assert history_size == 2
4212                    lock = self.lock()
4213                    assert lock
4214                    assert not self.nolock()
4215                    report = self.check(2.17, None, debug)
4216                    if debug:
4217                        print('report', report)
4218                    assert case[4] == report.valid
4219                    assert case[5] == report.statistics.overall_wealth
4220                    assert case[5] == report.statistics.zakatable_transactions_balance
4221
4222                    if debug:
4223                        pp().pprint(report.plan)
4224
4225                    for x in report.plan:
4226                        assert case[1] == x
4227                        if report.plan[x][0].below_nisab:
4228                            if debug:
4229                                print('[assert]', report.plan[x][0].total, case[3][0][x][0]['below_nisab'])
4230                            assert report.plan[x][0].total == case[3][0][x][0]['below_nisab']
4231                        else:
4232                            if debug:
4233                                print('[assert]', int(report.statistics.zakat_cut_balances), case[3][0][x][0]['total'])
4234                                print('[assert]', int(report.plan[x][0].total), case[3][0][x][0]['total'])
4235                                print('[assert]', report.plan[x][0].count ,case[3][0][x][0]['count'])
4236                            assert int(report.statistics.zakat_cut_balances) == case[3][0][x][0]['total']
4237                            assert int(report.plan[x][0].total) == case[3][0][x][0]['total']
4238                            assert report.plan[x][0].count == case[3][0][x][0]['count']
4239                    if debug:
4240                        pp().pprint(report)
4241                    result = self.zakat(report, debug=debug)
4242                    if debug:
4243                        print('zakat-result', result, case[4])
4244                    assert result == case[4]
4245                    report = self.check(2.17, None, debug)
4246                    assert report.valid is False
4247
4248            # storage
4249
4250            old_vault = dataclasses.replace(self.__vault)
4251            old_vault_deep = copy.deepcopy(self.__vault)
4252            old_vault_dict = dataclasses.asdict(self.__vault)
4253            _path = self.path(f'./zakat_test_db/test.{self.ext()}')
4254            if os.path.exists(_path):
4255                os.remove(_path)
4256            for hashed in [False, True]:
4257                self.save(hash_required=hashed)
4258                assert os.path.getsize(_path) > 0
4259                self.reset()
4260                assert self.recall(False, debug=debug) is False
4261                for hash_required in [False, True]:
4262                    if debug:
4263                        print(f'[storage] save({hashed}) and load({hash_required}) = {hashed and hash_required}')
4264                    self.load(hash_required=hashed and hash_required)
4265                    if debug:
4266                        print('[debug]', type(self.__vault))
4267                    assert self.__vault.account is not None
4268                    assert old_vault == self.__vault
4269                    assert old_vault_deep == self.__vault
4270                    assert old_vault_dict == dataclasses.asdict(self.__vault)
4271                    # corrupt the data
4272                    log_ref = NO_TIME()
4273                    tmp_file_ref = Time.time()
4274                    for k in self.__vault.account['cave'].log:
4275                        log_ref = k
4276                        self.__vault.account['cave'].log[k].file[tmp_file_ref] = 'HACKED'
4277                        break
4278                    assert old_vault != self.__vault
4279                    assert old_vault_deep != self.__vault
4280                    assert old_vault_dict != dataclasses.asdict(self.__vault)
4281                    # fix the data
4282                    del self.__vault.account['cave'].log[log_ref].file[tmp_file_ref]
4283                    assert old_vault == self.__vault
4284                    assert old_vault_deep == self.__vault
4285                    assert old_vault_dict == dataclasses.asdict(self.__vault)
4286                if hashed:
4287                    continue
4288                failed = False
4289                try:
4290                    hash_required = True
4291                    if debug:
4292                        print(f'x [storage] save({hashed}) and load({hash_required}) = {hashed and hash_required}')
4293                    self.load(hash_required=True)
4294                except:
4295                    failed = True
4296                assert failed
4297
4298            # recall after zakat
4299
4300            history_size = len(self.__vault.history)
4301            if debug:
4302                print('history_size', history_size)
4303            assert history_size == 3
4304            assert not self.nolock()
4305            assert self.recall(False, debug=debug) is False
4306            self.free(lock)
4307            assert self.nolock()
4308
4309            for i in range(3, 0, -1):
4310                history_size = len(self.__vault.history)
4311                if debug:
4312                    print('history_size', history_size)
4313                assert history_size == i
4314                assert self.recall(False, debug=debug) is True
4315
4316            assert self.nolock()
4317            assert self.recall(False, debug=debug) is False
4318
4319            history_size = len(self.__vault.history)
4320            if debug:
4321                print('history_size', history_size)
4322            assert history_size == 0
4323
4324            account_size = len(self.__vault.account)
4325            if debug:
4326                print('account_size', account_size)
4327            assert account_size == 0
4328
4329            report_size = len(self.__vault.report)
4330            if debug:
4331                print('report_size', report_size)
4332            assert report_size == 0
4333
4334            assert self.nolock()
4335
4336            # csv
4337
4338            csv_count = 1000
4339
4340            for with_rate, path in {
4341                False: 'test-import_csv-no-exchange',
4342                True: 'test-import_csv-with-exchange',
4343            }.items():
4344
4345                if debug:
4346                    print('test_import_csv', with_rate, path)
4347
4348                csv_path = path + '.csv'
4349                if os.path.exists(csv_path):
4350                    os.remove(csv_path)
4351                c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug)
4352                if debug:
4353                    print('generate_random_csv_file', c)
4354                assert c == csv_count
4355                assert os.path.getsize(csv_path) > 0
4356                cache_path = self.import_csv_cache_path()
4357                if os.path.exists(cache_path):
4358                    os.remove(cache_path)
4359                self.reset()
4360                lock = self.lock()
4361                (created, found, bad) = self.import_csv(csv_path, debug)
4362                bad_count = len(bad)
4363                if debug:
4364                    print(f'csv-imported: ({created}, {found}, {bad_count}) = count({csv_count})')
4365                    print('bad', bad)
4366                # TODO: assert created + found + bad_count == csv_count
4367                # TODO: assert created == csv_count
4368                # TODO: assert bad_count == 0
4369                assert bad_count > 0
4370                tmp_size = os.path.getsize(cache_path)
4371                assert tmp_size > 0
4372
4373                (created_2, found_2, bad_2) = self.import_csv(csv_path)
4374                bad_2_count = len(bad_2)
4375                if debug:
4376                    print(f'csv-imported: ({created_2}, {found_2}, {bad_2_count})')
4377                    print('bad', bad)
4378                assert bad_2_count > 0
4379                # TODO: assert tmp_size == os.path.getsize(cache_path)
4380                # TODO: assert created_2 + found_2 + bad_2_count == csv_count
4381                # TODO: assert created == found_2
4382                # TODO: assert bad_count == bad_2_count
4383                # TODO: assert found_2 == csv_count
4384                # TODO: assert bad_2_count == 0
4385                # TODO: assert created_2 == 0
4386
4387                # payment parts
4388
4389                positive_parts = self.build_payment_parts(100, positive_only=True)
4390                assert self.check_payment_parts(positive_parts) != 0
4391                assert self.check_payment_parts(positive_parts) != 0
4392                all_parts = self.build_payment_parts(300, positive_only=False)
4393                assert self.check_payment_parts(all_parts) != 0
4394                assert self.check_payment_parts(all_parts) != 0
4395                if debug:
4396                    pp().pprint(positive_parts)
4397                    pp().pprint(all_parts)
4398                # dynamic discount
4399                suite = []
4400                count = 3
4401                for exceed in [False, True]:
4402                    case = []
4403                    for part in [positive_parts, all_parts]:
4404                        #part = parts.copy()
4405                        demand = part.demand
4406                        if debug:
4407                            print(demand, part.total)
4408                        i = 0
4409                        z = demand / count
4410                        cp = PaymentParts(
4411                            demand=demand,
4412                            exceed=exceed,
4413                            total=part.total,
4414                        )
4415                        j = ''
4416                        for x, y in part.account.items():
4417                            x_exchange = self.exchange(x)
4418                            zz = self.exchange_calc(z, 1, x_exchange.rate)
4419                            if exceed and zz <= demand:
4420                                i += 1
4421                                y.part = zz
4422                                if debug:
4423                                    print(exceed, y)
4424                                cp.account[x] = y
4425                                case.append(y)
4426                            elif not exceed and y.balance >= zz:
4427                                i += 1
4428                                y.part = zz
4429                                if debug:
4430                                    print(exceed, y)
4431                                cp.account[x] = y
4432                                case.append(y)
4433                            j = x
4434                            if i >= count:
4435                                break
4436                        if debug:
4437                            print('[debug]', cp.account[j])
4438                        if cp.account[j] != AccountPaymentPart(0.0, 0.0, 0.0):
4439                            suite.append(cp)
4440                if debug:
4441                    print('suite', len(suite))
4442                for case in suite:
4443                    if debug:
4444                        print('case', case)
4445                    result = self.check_payment_parts(case)
4446                    if debug:
4447                        print('check_payment_parts', result, f'exceed: {exceed}')
4448                    assert result == 0
4449
4450                    report = self.check(2.17, None, debug)
4451                    if debug:
4452                        print('valid', report.valid)
4453                    zakat_result = self.zakat(report, parts=case, debug=debug)
4454                    if debug:
4455                        print('zakat-result', zakat_result)
4456                    assert report.valid == zakat_result
4457
4458                assert self.free(lock)
4459
4460            assert self.save(path + f'.{self.ext()}')
4461
4462            assert self.save(f'1000-transactions-test.{self.ext()}')
4463            return True
4464        except Exception as e:
4465            # pp().pprint(self.__vault)
4466            assert self.save(f'test-snapshot.{self.ext()}')
4467            raise e

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 json 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, save, load and more. These methods provide additional functionalities and flexibility for interacting with and managing the Zakat tracker.

Attributes:

Data Structure:

The ZakatTracker class utilizes a nested dataclasses structure called '__vault' to store and manage data.

__vault (dict):
    - account (dict):
        - {account_name} (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_name} (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.
ZakatTracker(db_path: str = './zakat_db/', history_mode: bool = True)
1032    def __init__(self, db_path: str = './zakat_db/', history_mode: bool = True):
1033        """
1034        Initialize ZakatTracker with database path and history mode.
1035
1036        Parameters:
1037        - db_path (str, optional): The path to the database  directory. Defaults to './zakat_db/'. Use ':memory:' for an in-memory database.
1038        - history_mode (bool, optional): The mode for tracking history. Default is True.
1039
1040        Returns:
1041        None
1042        """
1043        self.reset()
1044        self.__memory_mode = db_path == ':memory:'
1045        self.__history(history_mode)
1046        if not self.__memory_mode:
1047            self.path(f'{db_path}/db.{self.ext()}')

Initialize ZakatTracker with database path and history mode.

Parameters:

  • db_path (str, optional): The path to the database directory. Defaults to './zakat_db/'. Use ':memory:' for an in-memory database.
  • history_mode (bool, optional): The mode for tracking history. Default is True.

Returns: None

@staticmethod
def Version() -> str:
943    @staticmethod
944    def Version() -> str:
945        """
946        Returns the current version of the software.
947
948        This function returns a string representing the current version of the software,
949        including major, minor, and patch version numbers in the format 'X.Y.Z'.
950
951        Returns:
952        - str: The current version of the software.
953        """
954        version = '0.3.1'
955        git_hash, unstaged_count, commit_count_since_last_tag = get_git_status()
956        if git_hash and (unstaged_count > 0 or commit_count_since_last_tag > 0):
957            version += f".{commit_count_since_last_tag}dev{unstaged_count}+{git_hash}"
958            print(version)
959        return version

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.
@staticmethod
def ZakatCut(x: float) -> float:
961    @staticmethod
962    def ZakatCut(x: float) -> float:
963        """
964        Calculates the Zakat amount due on an asset.
965
966        This function calculates the zakat amount due on a given asset value over one lunar year.
967        Zakat is an Islamic obligatory alms-giving, calculated as a fixed percentage of an individual's wealth
968        that exceeds a certain threshold (Nisab).
969
970        Parameters:
971        - x (float): The total value of the asset on which Zakat is to be calculated.
972
973        Returns:
974        - float: The amount of Zakat due on the asset, calculated as 2.5% of the asset's value.
975        """
976        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 (float): The total value of the asset on which Zakat is to be calculated.

Returns:

  • float: The amount of Zakat due on the asset, calculated as 2.5% of the asset's value.
@staticmethod
def TimeCycle(days: int = 355) -> int:
978    @staticmethod
979    def TimeCycle(days: int = 355) -> int:
980        """
981        Calculates the approximate duration of a lunar year in nanoseconds.
982
983        This function calculates the approximate duration of a lunar year based on the given number of days.
984        It converts the given number of days into nanoseconds for use in high-precision timing applications.
985
986        Parameters:
987        - days (int, optional): The number of days in a lunar year. Defaults to 355,
988              which is an approximation of the average length of a lunar year.
989
990        Returns:
991        - int: The approximate duration of a lunar year in nanoseconds.
992        """
993        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 (int, optional): The number of days in a lunar year. Defaults to 355, which is an approximation of the average length of a lunar year.

Returns:

  • int: The approximate duration of a lunar year in nanoseconds.
@staticmethod
def Nisab(gram_price: float, gram_quantity: float = 595) -> float:
 995    @staticmethod
 996    def Nisab(gram_price: float, gram_quantity: float = 595) -> float:
 997        """
 998        Calculate the total value of Nisab (a unit of weight in Islamic jurisprudence) based on the given price per gram.
 999
1000        This function calculates the Nisab value, which is the minimum threshold of wealth,
1001        that makes an individual liable for paying Zakat.
1002        The Nisab value is determined by the equivalent value of a specific amount
1003        of gold or silver (currently 595 grams in silver) in the local currency.
1004
1005        Parameters:
1006        - gram_price (float): The price per gram of Nisab.
1007        - gram_quantity (float, optional): The quantity of grams in a Nisab. Default is 595 grams of silver.
1008
1009        Returns:
1010        - float: The total value of Nisab based on the given price per gram.
1011        """
1012        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, optional): 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.
@staticmethod
def ext() -> str:
1014    @staticmethod
1015    def ext() -> str:
1016        """
1017        Returns the file extension used by the ZakatTracker class.
1018
1019        Parameters:
1020        None
1021
1022        Returns:
1023        - str: The file extension used by the ZakatTracker class, which is 'json'.
1024        """
1025        return 'json'

Returns the file extension used by the ZakatTracker class.

Parameters: None

Returns:

  • str: The file extension used by the ZakatTracker class, which is 'json'.
def memory_mode(self) -> bool:
1049    def memory_mode(self) -> bool:
1050        """
1051        Check if the ZakatTracker is operating in memory mode.
1052
1053        Returns:
1054        - bool: True if the database is in memory, False otherwise.
1055        """
1056        return self.__memory_mode

Check if the ZakatTracker is operating in memory mode.

Returns:

  • bool: True if the database is in memory, False otherwise.
def path(self, path: Optional[str] = None) -> str:
1058    def path(self, path: Optional[str] = None) -> str:
1059        """
1060        Set or get the path to the database file.
1061
1062        If no path is provided, the current path is returned.
1063        If a path is provided, it is set as the new path.
1064        The function also creates the necessary directories if the provided path is a file.
1065
1066        Parameters:
1067        - path (str, optional): The new path to the database file. If not provided, the current path is returned.
1068
1069        Returns:
1070        - str: The current or new path to the database file.
1071        """
1072        if path is None:
1073            return self.__vault_path
1074        self.__vault_path = pathlib.Path(path).resolve()
1075        base_path = pathlib.Path(path).resolve()
1076        if base_path.is_file() or base_path.suffix:
1077            base_path = base_path.parent
1078        base_path.mkdir(parents=True, exist_ok=True)
1079        self.__base_path = base_path
1080        return str(self.__vault_path)

Set or get the path to the database file.

If no path is provided, the current path is returned. If a path is provided, it is set as the new path. The function also creates the necessary directories if the provided path is a file.

Parameters:

  • path (str, optional): The new path to the database file. If not provided, the current path is returned.

Returns:

  • str: The current or new path to the database file.
def base_path(self, *args) -> str:
1082    def base_path(self, *args) -> str:
1083        """
1084        Generate a base path by joining the provided arguments with the existing base path.
1085
1086        Parameters:
1087        - *args (str): Variable length argument list of strings to be joined with the base path.
1088
1089        Returns:
1090        - str: The generated base path. If no arguments are provided, the existing base path is returned.
1091        """
1092        if not args:
1093            return str(self.__base_path)
1094        filtered_args = []
1095        ignored_filename = None
1096        for arg in args:
1097            if pathlib.Path(arg).suffix:
1098                ignored_filename = arg
1099            else:
1100                filtered_args.append(arg)
1101        base_path = pathlib.Path(self.__base_path)
1102        full_path = base_path.joinpath(*filtered_args)
1103        full_path.mkdir(parents=True, exist_ok=True)
1104        if ignored_filename is not None:
1105            return full_path.resolve() / ignored_filename  # Join with the ignored filename
1106        return str(full_path.resolve())

Generate a base path by joining the provided arguments with the existing base path.

Parameters:

  • *args (str): Variable length argument list of strings to be joined with the base path.

Returns:

  • str: The generated base path. If no arguments are provided, the existing base path is returned.
@staticmethod
def scale(x: float | int | decimal.Decimal, decimal_places: int = 2) -> int:
1108    @staticmethod
1109    def scale(x: float | int | decimal.Decimal, decimal_places: int = 2) -> int:
1110        """
1111        Scales a numerical value by a specified power of 10, returning an integer.
1112
1113        This function is designed to handle various numeric types (`float`, `int`, or `decimal.Decimal`) and
1114        facilitate precise scaling operations, particularly useful in financial or scientific calculations.
1115
1116        Parameters:
1117        - x (float | int | decimal.Decimal): The numeric value to scale. Can be a floating-point number, integer, or decimal.
1118        - decimal_places (int, optional): The exponent for the scaling factor (10**y). Defaults to 2, meaning the input is scaled
1119            by a factor of 100 (e.g., converts 1.23 to 123).
1120
1121        Returns:
1122        - The scaled value, rounded to the nearest integer.
1123
1124        Raises:
1125        - TypeError: If the input `x` is not a valid numeric type.
1126
1127        Examples:
1128        ```bash
1129        >>> ZakatTracker.scale(3.14159)
1130        314
1131        >>> ZakatTracker.scale(1234, decimal_places=3)
1132        1234000
1133        >>> ZakatTracker.scale(decimal.Decimal('0.005'), decimal_places=4)
1134        50
1135        ```
1136        """
1137        if not isinstance(x, (float, int, decimal.Decimal)):
1138            raise TypeError(f'Input "{x}" must be a float, int, or decimal.Decimal.')
1139        return int(decimal.Decimal(f'{x:.{decimal_places}f}') * (10 ** decimal_places))

Scales a numerical value by a specified power of 10, returning an integer.

This function is designed to handle various numeric types (float, int, or decimal.Decimal) and facilitate precise scaling operations, particularly useful in financial or scientific calculations.

Parameters:

  • x (float | int | decimal.Decimal): The numeric value to scale. Can be a floating-point number, integer, or decimal.
  • decimal_places (int, optional): The exponent for the scaling factor (10**y). Defaults to 2, meaning the input is scaled by a factor of 100 (e.g., converts 1.23 to 123).

Returns:

  • The scaled value, rounded to the nearest integer.

Raises:

  • TypeError: If the input x is not a valid numeric type.

Examples:

>>> ZakatTracker.scale(3.14159)
314
>>> ZakatTracker.scale(1234, decimal_places=3)
1234000
>>> ZakatTracker.scale(decimal.Decimal('0.005'), decimal_places=4)
50
@staticmethod
def unscale( x: int, return_type: type = <class 'float'>, decimal_places: int = 2) -> float | decimal.Decimal:
1141    @staticmethod
1142    def unscale(x: int, return_type: type = float, decimal_places: int = 2) -> float | decimal.Decimal:
1143        """
1144        Unscales an integer by a power of 10.
1145
1146        Parameters:
1147        - x (int): The integer to unscale.
1148        - return_type (type, optional): The desired type for the returned value. Can be float, int, or decimal.Decimal. Defaults to float.
1149        - decimal_places (int, optional): The power of 10 to use. Defaults to 2.
1150
1151        Returns:
1152        - float | int | decimal.Decimal: The unscaled number, converted to the specified return_type.
1153
1154        Raises:
1155        - TypeError: If the return_type is not float or decimal.Decimal.
1156        """
1157        if return_type not in (float, decimal.Decimal):
1158            raise TypeError(f'Invalid return_type({return_type}). Supported types are float, int, and decimal.Decimal.')
1159        return round(return_type(x / (10 ** decimal_places)), decimal_places)

Unscales an integer by a power of 10.

Parameters:

  • x (int): The integer to unscale.
  • return_type (type, optional): The desired type for the returned value. Can be float, int, or decimal.Decimal. Defaults to float.
  • decimal_places (int, optional): The power of 10 to use. Defaults to 2.

Returns:

  • float | int | decimal.Decimal: The unscaled number, converted to the specified return_type.

Raises:

  • TypeError: If the return_type is not float or decimal.Decimal.
def reset(self) -> None:
1161    def reset(self) -> None:
1162        """
1163        Reset the internal data structure to its initial state.
1164
1165        Parameters:
1166        None
1167
1168        Returns:
1169        None
1170        """
1171        self.__vault = Vault()

Reset the internal data structure to its initial state.

Parameters: None

Returns: None

def clean_history(self, lock: Optional[Timestamp] = None) -> int:
1173    def clean_history(self, lock: Optional[Timestamp] = None) -> int:
1174        """
1175        Cleans up the empty history records of actions performed on the ZakatTracker instance.
1176
1177        Parameters:
1178        - lock (Timestamp, optional): The lock ID is used to clean up the empty history.
1179            If not provided, it cleans up the empty history records for all locks.
1180
1181        Returns:
1182        - int: The number of locks cleaned up.
1183        """
1184        count = 0
1185        if lock in self.__vault.history:
1186            if len(self.__vault.history[lock]) <= 0:
1187                count += 1
1188                del self.__vault.history[lock]
1189            return count
1190        for key in self.__vault.history:
1191            if len(self.__vault.history[key]) <= 0:
1192                count += 1
1193                del self.__vault.history[key]
1194        return count

Cleans up the empty history records of actions performed on the ZakatTracker instance.

Parameters:

  • lock (Timestamp, optional): The lock ID is used to clean up the empty history. If not provided, it cleans up the empty history records for all locks.

Returns:

  • int: The number of locks cleaned up.
def nolock(self) -> bool:
1264    def nolock(self) -> bool:
1265        """
1266        Check if the vault lock is currently not set.
1267
1268        Parameters:
1269        None
1270
1271        Returns:
1272        - bool: True if the vault lock is not set, False otherwise.
1273        """
1274        return self.__vault.lock is None

Check if the vault lock is currently not set.

Parameters: None

Returns:

  • bool: True if the vault lock is not set, False otherwise.
def lock(self) -> Optional[Timestamp]:
1289    def lock(self) -> Optional[Timestamp]:
1290        """
1291        Acquires a lock on the ZakatTracker instance.
1292
1293        Parameters:
1294        None
1295
1296        Returns:
1297        - Optional[Timestamp]: The lock ID. This ID can be used to release the lock later.
1298        """
1299        return self.__step()

Acquires a lock on the ZakatTracker instance.

Parameters: None

Returns:

  • Optional[Timestamp]: The lock ID. This ID can be used to release the lock later.
def steps(self) -> dict:
1301    def steps(self) -> dict:
1302        """
1303        Returns a copy of the history of steps taken in the ZakatTracker.
1304
1305        The history is a dictionary where each key is a unique identifier for a step,
1306        and the corresponding value is a dictionary containing information about the step.
1307
1308        Parameters:
1309        None
1310
1311        Returns:
1312        - dict: A copy of the history of steps taken in the ZakatTracker.
1313        """
1314        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.

Parameters: None

Returns:

  • dict: A copy of the history of steps taken in the ZakatTracker.
def free( self, lock: Timestamp, auto_save: bool = True) -> bool:
1316    def free(self, lock: Timestamp, auto_save: bool = True) -> bool:
1317        """
1318        Releases the lock on the database.
1319
1320        Parameters:
1321        - lock (Timestamp): The lock ID to be released.
1322        - auto_save (bool, optional): Whether to automatically save the database after releasing the lock.
1323
1324        Returns:
1325        - bool: True if the lock is successfully released and (optionally) saved, False otherwise.
1326        """
1327        if lock == self.__vault.lock:
1328            self.clean_history(lock)
1329            self.__vault.lock = None
1330            if auto_save and not self.memory_mode():
1331                return self.save(self.path())
1332            return True
1333        return False

Releases the lock on the database.

Parameters:

  • lock (Timestamp): The lock ID to be released.
  • auto_save (bool, optional): Whether to automatically save the database after releasing the lock.

Returns:

  • bool: True if the lock is successfully released and (optionally) saved, False otherwise.
def recall( self, dry: bool = True, lock: Optional[Timestamp] = None, debug: bool = False) -> bool:
1335    def recall(self, dry: bool = True, lock: Optional[Timestamp] = None, debug: bool = False) -> bool:
1336        """
1337        Revert the last operation.
1338
1339        Parameters:
1340        - dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
1341        - lock (Timestamp, optional): An optional lock value to ensure the recall
1342                operation is performed on the expected history entry. If provided,
1343                it checks if the current lock and the most recent history key
1344                match the given lock value. Defaults to None.
1345        - debug (bool, optional): If True, the function will print debug information. Default is False.
1346
1347        Returns:
1348        - bool: True if the operation was successful, False otherwise.
1349        """
1350        if not self.nolock() or len(self.__vault.history) == 0:
1351            return False
1352        if len(self.__vault.history) <= 0:
1353            return False
1354        ref = sorted(self.__vault.history.keys())[-1]
1355        if debug:
1356            print('recall', ref)
1357        memory = self.__vault.history[ref]
1358        if debug:
1359            print(type(memory), 'memory', memory)
1360        if lock is not None:
1361            assert self.__vault.lock == lock, "Invalid current lock"
1362            assert ref == lock, "Invalid last lock"
1363            assert self.__history(), "History mode should be enabled, found off!!!"
1364        limit = len(memory) + 1
1365        sub_positive_log_negative = 0
1366        for i in range(-1, -limit, -1):
1367            x = memory[i]
1368            if debug:
1369                print(type(x), x)
1370            match x.action:
1371                case Action.CREATE:
1372                    if x.account is not None:
1373                        if self.account_exists(x.account):
1374                            if debug:
1375                                print('account', self.__vault.account[x.account])
1376                            assert len(self.__vault.account[x.account].box) == 0
1377                            assert len(self.__vault.account[x.account].log) == 0
1378                            assert self.__vault.account[x.account].balance == 0
1379                            assert self.__vault.account[x.account].count == 0
1380                            if dry:
1381                                continue
1382                            del self.__vault.account[x.account]
1383
1384                case Action.TRACK:
1385                    if x.account is not None:
1386                        if self.account_exists(x.account):
1387                            if dry:
1388                                continue
1389                            assert x.value is not None
1390                            assert x.ref is not None
1391                            self.__vault.account[x.account].balance -= x.value
1392                            self.__vault.account[x.account].count -= 1
1393                            del self.__vault.account[x.account].box[x.ref]
1394
1395                case Action.LOG:
1396                    if x.account is not None:
1397                        if self.account_exists(x.account):
1398                            if x.ref in self.__vault.account[x.account].log:
1399                                if dry:
1400                                    continue
1401                                assert x.value is not None
1402                                if sub_positive_log_negative == -x.value:
1403                                    self.__vault.account[x.account].count -= 1
1404                                    sub_positive_log_negative = 0
1405                                box_ref = self.__vault.account[x.account].log[x.ref].ref
1406                                if not box_ref is None:
1407                                    assert self.box_exists(x.account, box_ref)
1408                                    box_value = self.__vault.account[x.account].log[x.ref].value
1409                                    assert box_value < 0
1410
1411                                    try:
1412                                        self.__vault.account[x.account].box[box_ref].rest += -box_value
1413                                    except TypeError:
1414                                        self.__vault.account[x.account].box[box_ref].rest += decimal.Decimal(-box_value)
1415
1416                                    try:
1417                                        self.__vault.account[x.account].balance += -box_value
1418                                    except TypeError:
1419                                        self.__vault.account[x.account].balance += decimal.Decimal(-box_value)
1420
1421                                    self.__vault.account[x.account].count -= 1
1422                                del self.__vault.account[x.account].log[x.ref]
1423
1424                case Action.SUBTRACT:
1425                    if x.account is not None:
1426                        if self.account_exists(x.account):
1427                            if x.ref in self.__vault.account[x.account].box:
1428                                if dry:
1429                                    continue
1430                                assert x.value is not None
1431                                self.__vault.account[x.account].box[x.ref].rest += x.value
1432                                self.__vault.account[x.account].balance += x.value
1433                                sub_positive_log_negative = x.value
1434
1435                case Action.ADD_FILE:
1436                    if x.account is not None:
1437                        if self.account_exists(x.account):
1438                            if x.ref in self.__vault.account[x.account].log:
1439                                if x.file in self.__vault.account[x.account].log[x.ref].file:
1440                                    if dry:
1441                                        continue
1442                                    del self.__vault.account[x.account].log[x.ref].file[x.file]
1443
1444                case Action.REMOVE_FILE:
1445                    if x.account is not None:
1446                        if self.account_exists(x.account):
1447                            if x.ref in self.__vault.account[x.account].log:
1448                                if dry:
1449                                    continue
1450                                assert x.file is not None
1451                                assert x.value is not None
1452                                self.__vault.account[x.account].log[x.ref].file[x.file] = x.value
1453
1454                case Action.BOX_TRANSFER:
1455                    if x.account is not None:
1456                        if self.account_exists(x.account):
1457                            if x.ref in self.__vault.account[x.account].box:
1458                                if dry:
1459                                    continue
1460                                assert x.value is not None
1461                                self.__vault.account[x.account].box[x.ref].rest -= x.value
1462
1463                case Action.EXCHANGE:
1464                    if x.account is not None:
1465                        if x.account in self.__vault.exchange:
1466                            if x.ref in self.__vault.exchange[x.account]:
1467                                if dry:
1468                                    continue
1469                                del self.__vault.exchange[x.account][x.ref]
1470
1471                case Action.REPORT:
1472                    if x.ref in self.__vault.report:
1473                        if dry:
1474                            continue
1475                        del self.__vault.report[x.ref]
1476
1477                case Action.ZAKAT:
1478                    if x.account is not None:
1479                        if self.account_exists(x.account):
1480                            if x.ref in self.__vault.account[x.account].box:
1481                                assert x.key is not None
1482                                if hasattr(self.__vault.account[x.account].box[x.ref], x.key):
1483                                    if dry:
1484                                        continue
1485                                    match x.math:
1486                                        case MathOperation.ADDITION:
1487                                            setattr(
1488                                                self.__vault.account[x.account].box[x.ref],
1489                                                x.key,
1490                                                getattr(self.__vault.account[x.account].box[x.ref], x.key) - x.value,
1491                                            )
1492                                        case MathOperation.EQUAL:
1493                                            setattr(
1494                                                self.__vault.account[x.account].box[x.ref],
1495                                                x.key,
1496                                                x.value,
1497                                            )
1498                                        case MathOperation.SUBTRACTION:
1499                                            setattr(
1500                                                self.__vault.account[x.account].box[x.ref],
1501                                                x.key,
1502                                                getattr(self.__vault.account[x.account].box[x.ref], x.key) + x.value,
1503                                            )
1504
1505        if not dry:
1506            del self.__vault.history[ref]
1507        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.
  • lock (Timestamp, optional): An optional lock value to ensure the recall operation is performed on the expected history entry. If provided, it checks if the current lock and the most recent history key match the given lock value. Defaults to None.
  • debug (bool, optional): If True, the function will print debug information. Default is False.

Returns:

  • bool: True if the operation was successful, False otherwise.
def vault(self) -> dict:
1509    def vault(self) -> dict:
1510        """
1511        Returns a copy of the internal vault dictionary.
1512
1513        This method is used to retrieve the current state of the ZakatTracker object.
1514        It provides a snapshot of the internal data structure, allowing for further
1515        processing or analysis.
1516
1517        Parameters:
1518        None
1519
1520        Returns:
1521        - dict: A copy of the internal vault dictionary.
1522        """
1523        return dataclasses.asdict(self.__vault)

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.

Parameters: None

Returns:

  • dict: A copy of the internal vault dictionary.
@staticmethod
def stats_init() -> dict[str, tuple[int, str]]:
1525    @staticmethod
1526    def stats_init() -> dict[str, tuple[int, str]]:
1527        """
1528        Initialize and return a dictionary containing initial statistics for the ZakatTracker instance.
1529
1530        The dictionary contains two keys: 'database' and 'ram'. Each key maps to a tuple containing two elements:
1531        - The initial size of the respective statistic in bytes (int).
1532        - The initial size of the respective statistic in a human-readable format (str).
1533
1534        Parameters:
1535        None
1536
1537        Returns:
1538        - dict[str, tuple]: A dictionary with initial statistics for the ZakatTracker instance.
1539        """
1540        return {
1541            'database': (0, '0'),
1542            'ram': (0, '0'),
1543        }

Initialize and return a dictionary containing initial statistics for the ZakatTracker instance.

The dictionary contains two keys: 'database' and 'ram'. Each key maps to a tuple containing two elements:

  • The initial size of the respective statistic in bytes (int).
  • The initial size of the respective statistic in a human-readable format (str).

Parameters: None

Returns:

  • dict[str, tuple]: A dictionary with initial statistics for the ZakatTracker instance.
def stats(self, ignore_ram: bool = True) -> dict[str, tuple[float, str]]:
1545    def stats(self, ignore_ram: bool = True) -> dict[str, tuple[float, str]]:
1546        """
1547        Calculates and returns statistics about the object's data storage.
1548
1549        This method determines the size of the database file on disk and the
1550        size of the data currently held in RAM (likely within a dictionary).
1551        Both sizes are reported in bytes and in a human-readable format
1552        (e.g., KB, MB).
1553
1554        Parameters:
1555        - ignore_ram (bool, optional): Whether to ignore the RAM size. Default is True
1556
1557        Returns:
1558        - dict[str, tuple[float, str]]: A dictionary containing the following statistics:
1559
1560            * 'database': A tuple with two elements:
1561                - The database file size in bytes (float).
1562                - The database file size in human-readable format (str).
1563            * 'ram': A tuple with two elements:
1564                - The RAM usage (dictionary size) in bytes (float).
1565                - The RAM usage in human-readable format (str).
1566
1567        Example:
1568        ```bash
1569        >>> x = ZakatTracker()
1570        >>> stats = x.stats()
1571        >>> print(stats['database'])
1572        (256000, '250.0 KB')
1573        >>> print(stats['ram'])
1574        (12345, '12.1 KB')
1575        ```
1576        """
1577        ram_size = 0.0 if ignore_ram else self.get_dict_size(self.vault())
1578        file_size = os.path.getsize(self.path())
1579        return {
1580            'database': (file_size, self.human_readable_size(file_size)),
1581            'ram': (ram_size, self.human_readable_size(ram_size)),
1582        }

Calculates and returns statistics about the object's data storage.

This method determines the size of the database file on disk and the size of the data currently held in RAM (likely within a dictionary). Both sizes are reported in bytes and in a human-readable format (e.g., KB, MB).

Parameters:

  • ignore_ram (bool, optional): Whether to ignore the RAM size. Default is True

Returns:

  • dict[str, tuple[float, str]]: A dictionary containing the following statistics:
* 'database': A tuple with two elements:
    - The database file size in bytes (float).
    - The database file size in human-readable format (str).
* 'ram': A tuple with two elements:
    - The RAM usage (dictionary size) in bytes (float).
    - The RAM usage in human-readable format (str).

Example:

>>> x = ZakatTracker()
>>> stats = x.stats()
>>> print(stats['database'])
(256000, '250.0 KB')
>>> print(stats['ram'])
(12345, '12.1 KB')
def files(self) -> list[dict[str, str | int]]:
1584    def files(self) -> list[dict[str, str | int]]:
1585        """
1586        Retrieves information about files associated with this class.
1587
1588        This class method provides a standardized way to gather details about
1589        files used by the class for storage, snapshots, and CSV imports.
1590
1591        Parameters:
1592        None
1593
1594        Returns:
1595        - list[dict[str, str | int]]: A list of dictionaries, each containing information
1596            about a specific file:
1597
1598            * type (str): The type of file ('database', 'snapshot', 'import_csv').
1599            * path (str): The full file path.
1600            * exists (bool): Whether the file exists on the filesystem.
1601            * size (int): The file size in bytes (0 if the file doesn't exist).
1602            * human_readable_size (str): A human-friendly representation of the file size (e.g., '10 KB', '2.5 MB').
1603
1604        Example:
1605        ```
1606        file_info = MyClass.files()
1607        for info in file_info:
1608            print(f'Type: {info['type']}, Exists: {info['exists']}, Size: {info['human_readable_size']}')
1609        ```
1610        """
1611        result = []
1612        for file_type, path in {
1613            'database': self.path(),
1614            'snapshot': self.snapshot_cache_path(),
1615            'import_csv': self.import_csv_cache_path(),
1616        }.items():
1617            exists = os.path.exists(path)
1618            size = os.path.getsize(path) if exists else 0
1619            human_readable_size = self.human_readable_size(size) if exists else 0
1620            result.append({
1621                'type': file_type,
1622                'path': path,
1623                'exists': exists,
1624                'size': size,
1625                'human_readable_size': human_readable_size,
1626            })
1627        return result

Retrieves information about files associated with this class.

This class method provides a standardized way to gather details about files used by the class for storage, snapshots, and CSV imports.

Parameters: None

Returns:

  • list[dict[str, str | int]]: A list of dictionaries, each containing information about a specific file:
* type (str): The type of file ('database', 'snapshot', 'import_csv').
* path (str): The full file path.
* exists (bool): Whether the file exists on the filesystem.
* size (int): The file size in bytes (0 if the file doesn't exist).
* human_readable_size (str): A human-friendly representation of the file size (e.g., '10 KB', '2.5 MB').

Example:

file_info = MyClass.files()
for info in file_info:
    print(f'Type: {info['type']}, Exists: {info['exists']}, Size: {info['human_readable_size']}')
def account_exists(self, account: AccountName) -> bool:
1629    def account_exists(self, account: AccountName) -> bool:
1630        """
1631        Check if the given account exists in the vault.
1632
1633        Parameters:
1634        - account (AccountName): The account number to check.
1635
1636        Returns:
1637        - bool: True if the account exists, False otherwise.
1638        """
1639        return account in self.__vault.account

Check if the given account exists in the vault.

Parameters:

  • account (AccountName): The account number to check.

Returns:

  • bool: True if the account exists, False otherwise.
def box_size(self, account: AccountName) -> int:
1641    def box_size(self, account: AccountName) -> int:
1642        """
1643        Calculate the size of the box for a specific account.
1644
1645        Parameters:
1646        - account (AccountName): The account number for which the box size needs to be calculated.
1647
1648        Returns:
1649        - int: The size of the box for the given account. If the account does not exist, -1 is returned.
1650        """
1651        if self.account_exists(account):
1652            return len(self.__vault.account[account].box)
1653        return -1

Calculate the size of the box for a specific account.

Parameters:

  • account (AccountName): 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.
def log_size(self, account: AccountName) -> int:
1655    def log_size(self, account: AccountName) -> int:
1656        """
1657        Get the size of the log for a specific account.
1658
1659        Parameters:
1660        - account (AccountName): The account number for which the log size needs to be calculated.
1661
1662        Returns:
1663        - int: The size of the log for the given account. If the account does not exist, -1 is returned.
1664        """
1665        if self.account_exists(account):
1666            return len(self.__vault.account[account].log)
1667        return -1

Get the size of the log for a specific account.

Parameters:

  • account (AccountName): 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.
@staticmethod
def hash_data(data: bytes, algorithm: str = 'blake2b') -> str:
1669    @staticmethod
1670    def hash_data(data: bytes, algorithm: str = 'blake2b') -> str:
1671        """
1672        Calculates the hash of given byte data using the specified algorithm.
1673
1674        Parameters:
1675        - data (bytes): The byte data to hash.
1676        - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'.
1677
1678        Returns:
1679        - str: The hexadecimal representation of the data's hash.
1680        """
1681        hash_obj = hashlib.new(algorithm)
1682        hash_obj.update(data)
1683        return hash_obj.hexdigest()

Calculates the hash of given byte data using the specified algorithm.

Parameters:

  • data (bytes): The byte data to hash.
  • algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'.

Returns:

  • str: The hexadecimal representation of the data's hash.
@staticmethod
def hash_file(file_path: str, algorithm: str = 'blake2b') -> str:
1685    @staticmethod
1686    def hash_file(file_path: str, algorithm: str = 'blake2b') -> str:
1687        """
1688        Calculates the hash of a file using the specified algorithm.
1689
1690        Parameters:
1691        - file_path (str): The path to the file.
1692        - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'.
1693
1694        Returns:
1695        - str: The hexadecimal representation of the file's hash.
1696        """
1697        hash_obj = hashlib.new(algorithm)  # Create the hash object
1698        with open(file_path, 'rb') as file:  # Open file in binary mode for reading
1699            for chunk in iter(lambda: file.read(4096), b''):  # Read file in chunks
1700                hash_obj.update(chunk)
1701        return hash_obj.hexdigest()  # Return the hash as a hexadecimal string

Calculates the hash of a file using the specified algorithm.

Parameters:

  • file_path (str): The path to the file.
  • algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'.

Returns:

  • str: The hexadecimal representation of the file's hash.
def snapshot_cache_path(self):
1703    def snapshot_cache_path(self):
1704        """
1705        Generate the path for the cache file used to store snapshots.
1706
1707        The cache file is a json file that stores the timestamps of the snapshots.
1708        The file name is derived from the main database file name by replacing the '.json' extension with '.snapshots.json'.
1709
1710        Parameters:
1711        None
1712
1713        Returns:
1714        - str: The path to the cache file.
1715        """
1716        path = str(self.path())
1717        ext = self.ext()
1718        ext_len = len(ext)
1719        if path.endswith(f'.{ext}'):
1720            path = path[:-ext_len - 1]
1721        _, filename = os.path.split(path + f'.snapshots.{ext}')
1722        return self.base_path(filename)

Generate the path for the cache file used to store snapshots.

The cache file is a json file that stores the timestamps of the snapshots. The file name is derived from the main database file name by replacing the '.json' extension with '.snapshots.json'.

Parameters: None

Returns:

  • str: The path to the cache file.
def snapshot(self) -> bool:
1724    def snapshot(self) -> bool:
1725        """
1726        This function creates a snapshot of the current database state.
1727
1728        The function calculates the hash of the current database file and checks if a snapshot with the same hash already exists.
1729        If a snapshot with the same hash exists, the function returns True without creating a new snapshot.
1730        If a snapshot with the same hash does not exist, the function creates a new snapshot by saving the current database state
1731        in a new json file with a unique timestamp as the file name. The function also updates the snapshot cache file with the new snapshot's hash and timestamp.
1732
1733        Parameters:
1734        None
1735
1736        Returns:
1737        - bool: True if a snapshot with the same hash already exists or if the snapshot is successfully created. False if the snapshot creation fails.
1738        """
1739        current_hash = self.hash_file(self.path())
1740        cache: dict[str, int] = {}  # hash: time_ns
1741        try:
1742            with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream:
1743                cache = json.load(stream, cls=JSONDecoder)
1744        except:
1745            pass
1746        if current_hash in cache:
1747            return True
1748        ref = time.time_ns()
1749        cache[current_hash] = ref
1750        if not self.save(self.base_path('snapshots', f'{ref}.{self.ext()}')):
1751            return False
1752        with open(self.snapshot_cache_path(), 'w', encoding='utf-8') as stream:
1753            stream.write(json.dumps(cache, cls=JSONEncoder))
1754        return True

This function creates a snapshot of the current database state.

The function calculates the hash of the current database file and checks if a snapshot with the same hash already exists. If a snapshot with the same hash exists, the function returns True without creating a new snapshot. If a snapshot with the same hash does not exist, the function creates a new snapshot by saving the current database state in a new json file with a unique timestamp as the file name. The function also updates the snapshot cache file with the new snapshot's hash and timestamp.

Parameters: None

Returns:

  • bool: True if a snapshot with the same hash already exists or if the snapshot is successfully created. False if the snapshot creation fails.
def snapshots( self, hide_missing: bool = True, verified_hash_only: bool = False) -> dict[int, tuple[str, str, bool]]:
1756    def snapshots(self, hide_missing: bool = True, verified_hash_only: bool = False) \
1757            -> dict[int, tuple[str, str, bool]]:
1758        """
1759        Retrieve a dictionary of snapshots, with their respective hashes, paths, and existence status.
1760
1761        Parameters:
1762        - hide_missing (bool, optional): If True, only include snapshots that exist in the dictionary. Default is True.
1763        - verified_hash_only (bool, optional): If True, only include snapshots with a valid hash. Default is False.
1764
1765        Returns:
1766        - dict[int, tuple[str, str, bool]]: A dictionary where the keys are the timestamps of the snapshots,
1767        and the values are tuples containing the snapshot's hash, path, and existence status.
1768        """
1769        cache: dict[str, int] = {}  # hash: time_ns
1770        try:
1771            with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream:
1772                cache = json.load(stream, cls=JSONDecoder)
1773        except:
1774            pass
1775        if not cache:
1776            return {}
1777        result: dict[int, tuple[str, str, bool]] = {}  # time_ns: (hash, path, exists)
1778        for hash_file, ref in cache.items():
1779            path = self.base_path('snapshots', f'{ref}.{self.ext()}')
1780            exists = os.path.exists(path)
1781            valid_hash = self.hash_file(path) == hash_file if verified_hash_only else True
1782            if (verified_hash_only and not valid_hash) or (verified_hash_only and not exists):
1783                continue
1784            if exists or not hide_missing:
1785                result[ref] = (hash_file, path, exists)
1786        return result

Retrieve a dictionary of snapshots, with their respective hashes, paths, and existence status.

Parameters:

  • hide_missing (bool, optional): If True, only include snapshots that exist in the dictionary. Default is True.
  • verified_hash_only (bool, optional): If True, only include snapshots with a valid hash. Default is False.

Returns:

  • dict[int, tuple[str, str, bool]]: A dictionary where the keys are the timestamps of the snapshots, and the values are tuples containing the snapshot's hash, path, and existence status.
def ref_exists( self, account: AccountName, ref_type: str, ref: Timestamp) -> bool:
1788    def ref_exists(self, account: AccountName, ref_type: str, ref: Timestamp) -> bool:
1789        """
1790        Check if a specific reference (transaction) exists in the vault for a given account and reference type.
1791
1792        Parameters:
1793        - account (AccountName): The account number for which to check the existence of the reference.
1794        - ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
1795        - ref (Timestamp): The reference (transaction) number to check for existence.
1796
1797        Returns:
1798        - bool: True if the reference exists for the given account and reference type, False otherwise.
1799        """
1800        if account in self.__vault.account:
1801            return ref in getattr(self.__vault.account[account], ref_type)
1802        return False

Check if a specific reference (transaction) exists in the vault for a given account and reference type.

Parameters:

  • account (AccountName): 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 (Timestamp): The reference (transaction) number to check for existence.

Returns:

  • bool: True if the reference exists for the given account and reference type, False otherwise.
def box_exists( self, account: AccountName, ref: Timestamp) -> bool:
1804    def box_exists(self, account: AccountName, ref: Timestamp) -> bool:
1805        """
1806        Check if a specific box (transaction) exists in the vault for a given account and reference.
1807
1808        Parameters:
1809        - account (AccountName): The account number for which to check the existence of the box.
1810        - ref (Timestamp): The reference (transaction) number to check for existence.
1811
1812        Returns:
1813        - bool: True if the box exists for the given account and reference, False otherwise.
1814        """
1815        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 (AccountName): The account number for which to check the existence of the box.
  • ref (Timestamp): The reference (transaction) number to check for existence.

Returns:

  • bool: True if the box exists for the given account and reference, False otherwise.
def track( self, unscaled_value: float | int | decimal.Decimal = 0, desc: str = '', account: AccountName = '1', created_time_ns: Optional[Timestamp] = None, debug: bool = False) -> Timestamp:
1817    def track(self, unscaled_value: float | int | decimal.Decimal = 0, desc: str = '', account: AccountName = AccountName('1'),
1818              created_time_ns: Optional[Timestamp] = None,
1819              debug: bool = False) -> Timestamp:
1820        """
1821        This function tracks a transaction for a specific account, so it do creates a new account if it doesn't exist, logs the transaction if logging is True, and updates the account's balance and box.
1822
1823        Parameters:
1824        - unscaled_value (float | int | decimal.Decimal, optional): The value of the transaction. Default is 0.
1825        - desc (str, optional): The description of the transaction. Default is an empty string.
1826        - account (AccountName, optional): The account for which the transaction is being tracked. Default is '1'.
1827        - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, it will be generated. Default is None.
1828        - debug (bool, optional): Whether to print debug information. Default is False.
1829
1830        Returns:
1831        - Timestamp: The timestamp of the transaction in nanoseconds since epoch(1AD).
1832
1833        Raises:
1834        - ValueError: The created_time_ns should be greater than zero.
1835        - ValueError: The log transaction happened again in the same nanosecond time.
1836        - ValueError: The box transaction happened again in the same nanosecond time.
1837        """
1838        return self.__track(
1839            unscaled_value=unscaled_value,
1840            desc=desc,
1841            account=account,
1842            logging=True,
1843            created_time_ns=created_time_ns,
1844            debug=debug,
1845        )

This function tracks a transaction for a specific account, so it do creates a new account if it doesn't exist, logs the transaction if logging is True, and updates the account's balance and box.

Parameters:

  • unscaled_value (float | int | decimal.Decimal, optional): The value of the transaction. Default is 0.
  • desc (str, optional): The description of the transaction. Default is an empty string.
  • account (AccountName, optional): The account for which the transaction is being tracked. Default is '1'.
  • created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, it will be generated. Default is None.
  • debug (bool, optional): Whether to print debug information. Default is False.

Returns:

  • Timestamp: The timestamp of the transaction in nanoseconds since epoch(1AD).

Raises:

  • ValueError: The created_time_ns should be greater than zero.
  • ValueError: The log transaction happened again in the same nanosecond time.
  • ValueError: The box transaction happened again in the same nanosecond time.
def log_exists( self, account: AccountName, ref: Timestamp) -> bool:
1914    def log_exists(self, account: AccountName, ref: Timestamp) -> bool:
1915        """
1916        Checks if a specific transaction log entry exists for a given account.
1917
1918        Parameters:
1919        - account (AccountName): The account number associated with the transaction log.
1920        - ref (Timestamp): The reference to the transaction log entry.
1921
1922        Returns:
1923        - bool: True if the transaction log entry exists, False otherwise.
1924        """
1925        return self.ref_exists(account, 'log', ref)

Checks if a specific transaction log entry exists for a given account.

Parameters:

  • account (AccountName): The account number associated with the transaction log.
  • ref (Timestamp): The reference to the transaction log entry.

Returns:

  • bool: True if the transaction log entry exists, False otherwise.
def exchange( self, account: AccountName, created_time_ns: Optional[Timestamp] = None, rate: Optional[float] = None, description: Optional[str] = None, debug: bool = False) -> Exchange:
1977    def exchange(self, account: AccountName, created_time_ns: Optional[Timestamp] = None,
1978                 rate: Optional[float] = None, description: Optional[str] = None, debug: bool = False) -> Exchange:
1979        """
1980        This method is used to record or retrieve exchange rates for a specific account.
1981
1982        Parameters:
1983        - account (AccountName): The account number for which the exchange rate is being recorded or retrieved.
1984        - created_time_ns (Timestamp, optional): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
1985        - rate (float, optional): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
1986        - description (str, optional): A description of the exchange rate.
1987        - debug (bool, optional): Whether to print debug information. Default is False.
1988
1989        Returns:
1990        - Exchange: A dictionary containing the latest exchange rate and its description. If no exchange rate is found,
1991        it returns a dictionary with default values for the rate and description.
1992
1993        Raises:
1994        - ValueError: The created should be greater than zero.
1995        """
1996        if debug:
1997            print('exchange', f'debug={debug}')
1998        if created_time_ns is None:
1999            created_time_ns = Time.time()
2000        if created_time_ns <= 0:
2001            raise ValueError('The created should be greater than zero.')
2002        if rate is not None:
2003            if rate <= 0:
2004                return Exchange()
2005            if account not in self.__vault.exchange:
2006                self.__vault.exchange[account] = {}
2007            if len(self.__vault.exchange[account]) == 0 and rate <= 1:
2008                return Exchange(time=created_time_ns, rate=1)
2009            no_lock = self.nolock()
2010            lock = self.__lock()
2011            self.__vault.exchange[account][created_time_ns] = Exchange(rate=rate, description=description)
2012            self.__step(Action.EXCHANGE, account, ref=created_time_ns, value=rate)
2013            if no_lock:
2014                assert lock is not None
2015                self.free(lock)
2016            if debug:
2017                print('exchange-created-1',
2018                      f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}')
2019
2020        if account in self.__vault.exchange:
2021            valid_rates = [(ts, r) for ts, r in self.__vault.exchange[account].items() if ts <= created_time_ns]
2022            if valid_rates:
2023                latest_rate = max(valid_rates, key=lambda x: x[0])
2024                if debug:
2025                    print('exchange-read-1',
2026                          f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}',
2027                          'latest_rate', latest_rate)
2028                result = latest_rate[1]
2029                result.time = latest_rate[0]
2030                return result  # إرجاع قاموس يحتوي على المعدل والوصف
2031        if debug:
2032            print('exchange-read-0', f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}')
2033        return Exchange(time=created_time_ns, rate=1, description=None)  # إرجاع القيمة الافتراضية مع وصف فارغ

This method is used to record or retrieve exchange rates for a specific account.

Parameters:

  • account (AccountName): The account number for which the exchange rate is being recorded or retrieved.
  • created_time_ns (Timestamp, optional): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
  • rate (float, optional): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
  • description (str, optional): A description of the exchange rate.
  • debug (bool, optional): Whether to print debug information. Default is False.

Returns:

  • Exchange: 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.

Raises:

  • ValueError: The created should be greater than zero.
@staticmethod
def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
2035    @staticmethod
2036    def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
2037        """
2038        This function calculates the exchanged amount of a currency.
2039
2040        Parameters:
2041        - x (float): The original amount of the currency.
2042        - x_rate (float): The exchange rate of the original currency.
2043        - y_rate (float): The exchange rate of the target currency.
2044
2045        Returns:
2046        - float: The exchanged amount of the target currency.
2047        """
2048        return (x * x_rate) / y_rate

This function calculates the exchanged amount of a currency.

Parameters:

  • 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.
def exchanges(self) -> dict:
2050    def exchanges(self) -> dict:
2051        """
2052        Retrieve the recorded exchange rates for all accounts.
2053
2054        Parameters:
2055        None
2056
2057        Returns:
2058        - dict: A dictionary containing all recorded exchange rates.
2059        The keys are account names or numbers, and the values are dictionaries containing the exchange rates.
2060        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
2061        """
2062        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.
def accounts(self) -> dict:
2064    def accounts(self) -> dict:
2065        """
2066        Returns a dictionary containing account numbers as keys and their respective balances as values.
2067
2068        Parameters:
2069        None
2070
2071        Returns:
2072        - dict: A dictionary where keys are account numbers and values are their respective balances.
2073        """
2074        result = {}
2075        for i in self.__vault.account:
2076            result[i] = self.__vault.account[i].balance
2077        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.
def boxes(self, account: AccountName) -> dict:
2079    def boxes(self, account: AccountName) -> dict:
2080        """
2081        Retrieve the boxes (transactions) associated with a specific account.
2082
2083        Parameters:
2084        - account (AccountName): The account number for which to retrieve the boxes.
2085
2086        Returns:
2087        - dict: A dictionary containing the boxes associated with the given account.
2088        If the account does not exist, an empty dictionary is returned.
2089        """
2090        if self.account_exists(account):
2091            return self.__vault.account[account].box
2092        return {}

Retrieve the boxes (transactions) associated with a specific account.

Parameters:

  • account (AccountName): 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.
def logs( self, account: AccountName) -> dict[Timestamp, Log]:
2094    def logs(self, account: AccountName) -> dict[Timestamp, Log]:
2095        """
2096        Retrieve the logs (transactions) associated with a specific account.
2097
2098        Parameters:
2099        - account (AccountName): The account number for which to retrieve the logs.
2100
2101        Returns:
2102        - dict[Timestamp, Log]: A dictionary containing the logs associated with the given account.
2103        If the account does not exist, an empty dictionary is returned.
2104        """
2105        if self.account_exists(account):
2106            return self.__vault.account[account].log
2107        return {}

Retrieve the logs (transactions) associated with a specific account.

Parameters:

  • account (AccountName): The account number for which to retrieve the logs.

Returns:

  • dict[Timestamp, Log]: A dictionary containing the logs associated with the given account. If the account does not exist, an empty dictionary is returned.
@staticmethod
def daily_logs_init() -> dict[str, dict]:
2109    @staticmethod
2110    def daily_logs_init() -> dict[str, dict]:
2111        """
2112        Initialize a dictionary to store daily, weekly, monthly, and yearly logs.
2113
2114        Parameters:
2115        None
2116
2117        Returns:
2118        - dict: A dictionary with keys 'daily', 'weekly', 'monthly', and 'yearly', each containing an empty dictionary.
2119            Later each key maps to another dictionary, which will store the logs for the corresponding time period.
2120        """
2121        return {
2122            'daily': {},
2123            'weekly': {},
2124            'monthly': {},
2125            'yearly': {},
2126        }

Initialize a dictionary to store daily, weekly, monthly, and yearly logs.

Parameters: None

Returns:

  • dict: A dictionary with keys 'daily', 'weekly', 'monthly', and 'yearly', each containing an empty dictionary. Later each key maps to another dictionary, which will store the logs for the corresponding time period.
def daily_logs( self, weekday: WeekDay = <WeekDay.FRIDAY: 4>, debug: bool = False):
2128    def daily_logs(self, weekday: WeekDay = WeekDay.FRIDAY, debug: bool = False):
2129        """
2130        Retrieve the daily logs (transactions) from all accounts.
2131
2132        The function groups the logs by day, month, and year, and calculates the total value for each group.
2133        It returns a dictionary where the keys are the timestamps of the daily groups,
2134        and the values are dictionaries containing the total value and the logs for that group.
2135
2136        Parameters:
2137        - weekday (WeekDay, optional): Select the weekday is collected for the week data. Default is WeekDay.Friday.
2138        - debug (bool, optional): Whether to print debug information. Default is False.
2139
2140        Returns:
2141        - dict: A dictionary containing the daily logs.
2142
2143        Example:
2144        ```bash
2145        >>> tracker = ZakatTracker()
2146        >>> tracker.subtract(51, 'desc', 'account1')
2147        >>> ref = tracker.track(100, 'desc', 'account2')
2148        >>> tracker.add_file('account2', ref, 'file_0')
2149        >>> tracker.add_file('account2', ref, 'file_1')
2150        >>> tracker.add_file('account2', ref, 'file_2')
2151        >>> tracker.daily_logs()
2152        {
2153            'daily': {
2154                '2024-06-30': {
2155                    'positive': 100,
2156                    'negative': 51,
2157                    'total': 99,
2158                    'rows': [
2159                        {
2160                            'account': 'account1',
2161                            'desc': 'desc',
2162                            'file': {},
2163                            'ref': None,
2164                            'value': -51,
2165                            'time': 1690977015000000000,
2166                            'transfer': False,
2167                        },
2168                        {
2169                            'account': 'account2',
2170                            'desc': 'desc',
2171                            'file': {
2172                                1722919011626770944: 'file_0',
2173                                1722919011626812928: 'file_1',
2174                                1722919011626846976: 'file_2',
2175                            },
2176                            'ref': None,
2177                            'value': 100,
2178                            'time': 1690977015000000000,
2179                            'transfer': False,
2180                        },
2181                    ],
2182                },
2183            },
2184            'weekly': {
2185                datetime: {
2186                    'positive': 100,
2187                    'negative': 51,
2188                    'total': 99,
2189                },
2190            },
2191            'monthly': {
2192                '2024-06': {
2193                    'positive': 100,
2194                    'negative': 51,
2195                    'total': 99,
2196                },
2197            },
2198            'yearly': {
2199                2024: {
2200                    'positive': 100,
2201                    'negative': 51,
2202                    'total': 99,
2203                },
2204            },
2205        }
2206        ```
2207        """
2208        logs = {}
2209        for account in self.accounts():
2210            for k, v in self.logs(account).items():
2211                l = dataclasses.asdict(v)
2212                l['time'] = k
2213                l['account'] = account
2214                if k not in logs:
2215                    logs[k] = []
2216                logs[k].append(l)
2217        if debug:
2218            print('logs', logs)
2219        y = self.daily_logs_init()
2220        for i in sorted(logs, reverse=True):
2221            dt = Time.time_to_datetime(i)
2222            daily = f'{dt.year}-{dt.month:02d}-{dt.day:02d}'
2223            weekly = dt - datetime.timedelta(days=weekday.value)
2224            monthly = f'{dt.year}-{dt.month:02d}'
2225            yearly = dt.year
2226            # daily
2227            if daily not in y['daily']:
2228                y['daily'][daily] = {
2229                    'positive': 0,
2230                    'negative': 0,
2231                    'total': 0,
2232                    'rows': [],
2233                }
2234            transfer = len(logs[i]) > 1
2235            if debug:
2236                print('logs[i]', logs[i])
2237            for z in logs[i]:
2238                if debug:
2239                    print('z', z)
2240                # daily
2241                value = z['value']
2242                if value > 0:
2243                    y['daily'][daily]['positive'] += value
2244                else:
2245                    y['daily'][daily]['negative'] += -value
2246                y['daily'][daily]['total'] += value
2247                z['transfer'] = transfer
2248                y['daily'][daily]['rows'].append(z)
2249                # weekly
2250                if weekly not in y['weekly']:
2251                    y['weekly'][weekly] = {
2252                        'positive': 0,
2253                        'negative': 0,
2254                        'total': 0,
2255                    }
2256                if value > 0:
2257                    y['weekly'][weekly]['positive'] += value
2258                else:
2259                    y['weekly'][weekly]['negative'] += -value
2260                y['weekly'][weekly]['total'] += value
2261                # monthly
2262                if monthly not in y['monthly']:
2263                    y['monthly'][monthly] = {
2264                        'positive': 0,
2265                        'negative': 0,
2266                        'total': 0,
2267                    }
2268                if value > 0:
2269                    y['monthly'][monthly]['positive'] += value
2270                else:
2271                    y['monthly'][monthly]['negative'] += -value
2272                y['monthly'][monthly]['total'] += value
2273                # yearly
2274                if yearly not in y['yearly']:
2275                    y['yearly'][yearly] = {
2276                        'positive': 0,
2277                        'negative': 0,
2278                        'total': 0,
2279                    }
2280                if value > 0:
2281                    y['yearly'][yearly]['positive'] += value
2282                else:
2283                    y['yearly'][yearly]['negative'] += -value
2284                y['yearly'][yearly]['total'] += value
2285        if debug:
2286            print('y', y)
2287        return y

Retrieve the daily logs (transactions) from all accounts.

The function groups the logs by day, month, and year, and calculates the total value for each group. It returns a dictionary where the keys are the timestamps of the daily groups, and the values are dictionaries containing the total value and the logs for that group.

Parameters:

  • weekday (WeekDay, optional): Select the weekday is collected for the week data. Default is WeekDay.Friday.
  • debug (bool, optional): Whether to print debug information. Default is False.

Returns:

  • dict: A dictionary containing the daily logs.

Example:

>>> tracker = ZakatTracker()
>>> tracker.subtract(51, 'desc', 'account1')
>>> ref = tracker.track(100, 'desc', 'account2')
>>> tracker.add_file('account2', ref, 'file_0')
>>> tracker.add_file('account2', ref, 'file_1')
>>> tracker.add_file('account2', ref, 'file_2')
>>> tracker.daily_logs()
{
    'daily': {
        '2024-06-30': {
            'positive': 100,
            'negative': 51,
            'total': 99,
            'rows': [
                {
                    'account': 'account1',
                    'desc': 'desc',
                    'file': {},
                    'ref': None,
                    'value': -51,
                    'time': 1690977015000000000,
                    'transfer': False,
                },
                {
                    'account': 'account2',
                    'desc': 'desc',
                    'file': {
                        1722919011626770944: 'file_0',
                        1722919011626812928: 'file_1',
                        1722919011626846976: 'file_2',
                    },
                    'ref': None,
                    'value': 100,
                    'time': 1690977015000000000,
                    'transfer': False,
                },
            ],
        },
    },
    'weekly': {
        datetime: {
            'positive': 100,
            'negative': 51,
            'total': 99,
        },
    },
    'monthly': {
        '2024-06': {
            'positive': 100,
            'negative': 51,
            'total': 99,
        },
    },
    'yearly': {
        2024: {
            'positive': 100,
            'negative': 51,
            'total': 99,
        },
    },
}
def add_file( self, account: AccountName, ref: Timestamp, path: str) -> Timestamp:
2289    def add_file(self, account: AccountName, ref: Timestamp, path: str) -> Timestamp:
2290        """
2291        Adds a file reference to a specific transaction log entry in the vault.
2292
2293        Parameters:
2294        - account (AccountName): The account number associated with the transaction log.
2295        - ref (Timestamp): The reference to the transaction log entry.
2296        - path (str): The path of the file to be added.
2297
2298        Returns:
2299        - Timestamp: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
2300        """
2301        if self.account_exists(account):
2302            if ref in self.__vault.account[account].log:
2303                no_lock = self.nolock()
2304                lock = self.__lock()
2305                file_ref = Time.time()
2306                self.__vault.account[account].log[ref].file[file_ref] = path
2307                self.__step(Action.ADD_FILE, account, ref=ref, file=file_ref)
2308                if no_lock:
2309                    assert lock is not None
2310                    self.free(lock)
2311                return file_ref
2312        return Timestamp(0)

Adds a file reference to a specific transaction log entry in the vault.

Parameters:

  • account (AccountName): The account number associated with the transaction log.
  • ref (Timestamp): The reference to the transaction log entry.
  • path (str): The path of the file to be added.

Returns:

  • Timestamp: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
def remove_file( self, account: AccountName, ref: Timestamp, file_ref: Timestamp) -> bool:
2314    def remove_file(self, account: AccountName, ref: Timestamp, file_ref: Timestamp) -> bool:
2315        """
2316        Removes a file reference from a specific transaction log entry in the vault.
2317
2318        Parameters:
2319        - account (AccountName): The account number associated with the transaction log.
2320        - ref (Timestamp): The reference to the transaction log entry.
2321        - file_ref (Timestamp): The reference of the file to be removed.
2322
2323        Returns:
2324        - bool: True if the file reference is successfully removed, False otherwise.
2325        """
2326        if self.account_exists(account):
2327            if ref in self.__vault.account[account].log:
2328                if file_ref in self.__vault.account[account].log[ref].file:
2329                    no_lock = self.nolock()
2330                    lock = self.__lock()
2331                    x = self.__vault.account[account].log[ref].file[file_ref]
2332                    del self.__vault.account[account].log[ref].file[file_ref]
2333                    self.__step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x)
2334                    if no_lock:
2335                        assert lock is not None
2336                        self.free(lock)
2337                    return True
2338        return False

Removes a file reference from a specific transaction log entry in the vault.

Parameters:

  • account (AccountName): The account number associated with the transaction log.
  • ref (Timestamp): The reference to the transaction log entry.
  • file_ref (Timestamp): The reference of the file to be removed.

Returns:

  • bool: True if the file reference is successfully removed, False otherwise.
def balance( self, account: AccountName = '1', cached: bool = True) -> int:
2340    def balance(self, account: AccountName = AccountName('1'), cached: bool = True) -> int:
2341        """
2342        Calculate and return the balance of a specific account.
2343
2344        Parameters:
2345        - account (AccountName, optional): The account number. Default is '1'.
2346        - cached (bool, optional): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
2347
2348        Returns:
2349        - int: The balance of the account.
2350
2351        Notes:
2352        - If cached is True, the function returns the cached balance.
2353        - If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
2354        """
2355        if cached:
2356            return self.__vault.account[account].balance
2357        x = 0
2358        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 (AccountName, optional): The account number. Default is '1'.
  • cached (bool, optional): If True, use the cached balance. If False, calculate the balance from the box. Default is True.

Returns:

  • int: The balance of the account.

Notes:

  • 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.
def hide( self, account: AccountName, status: Optional[bool] = None) -> bool:
2360    def hide(self, account: AccountName, status: Optional[bool] = None) -> bool:
2361        """
2362        Check or set the hide status of a specific account.
2363
2364        Parameters:
2365        - account (AccountName): The account number.
2366        - status (bool, optional): The new hide status. If not provided, the function will return the current status.
2367
2368        Returns:
2369        - bool: The current or updated hide status of the account.
2370
2371        Raises:
2372        None
2373
2374        Example:
2375        ```bash
2376        >>> tracker = ZakatTracker()
2377        >>> ref = tracker.track(51, 'desc', 'account1')
2378        >>> tracker.hide('account1')  # Set the hide status of 'account1' to True
2379        False
2380        >>> tracker.hide('account1', True)  # Set the hide status of 'account1' to True
2381        True
2382        >>> tracker.hide('account1')  # Get the hide status of 'account1' by default
2383        True
2384        >>> tracker.hide('account1', False)
2385        False
2386        ```
2387        """
2388        if self.account_exists(account):
2389            if status is None:
2390                return self.__vault.account[account].hide
2391            self.__vault.account[account].hide = status
2392            return status
2393        return False

Check or set the hide status of a specific account.

Parameters:

  • account (AccountName): 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
def zakatable( self, account: AccountName, status: Optional[bool] = None) -> bool:
2395    def zakatable(self, account: AccountName, status: Optional[bool] = None) -> bool:
2396        """
2397        Check or set the zakatable status of a specific account.
2398
2399        Parameters:
2400        - account (AccountName): The account number.
2401        - status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
2402
2403        Returns:
2404        - bool: The current or updated zakatable status of the account.
2405
2406        Raises:
2407        None
2408
2409        Example:
2410        ```bash
2411        >>> tracker = ZakatTracker()
2412        >>> ref = tracker.track(51, 'desc', 'account1')
2413        >>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
2414        True
2415        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
2416        True
2417        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
2418        True
2419        >>> tracker.zakatable('account1', False)
2420        False
2421        ```
2422        """
2423        if self.account_exists(account):
2424            if status is None:
2425                return self.__vault.account[account].zakatable
2426            self.__vault.account[account].zakatable = status
2427            return status
2428        return False

Check or set the zakatable status of a specific account.

Parameters:

  • account (AccountName): 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
def subtract( self, unscaled_value: float | int | decimal.Decimal, desc: str = '', account: AccountName = '1', created_time_ns: Optional[Timestamp] = None, debug: bool = False) -> SubtractReport:
2430    def subtract(self, unscaled_value: float | int | decimal.Decimal, desc: str = '', account: AccountName = AccountName('1'),
2431            created_time_ns: Optional[Timestamp] = None,
2432            debug: bool = False) \
2433            -> SubtractReport:
2434        """
2435        Subtracts a specified value from an account's balance, if the amount to subtract is greater than the account's balance,
2436        the remaining amount will be transferred to a new transaction with a negative value.
2437
2438        Parameters:
2439        - unscaled_value (float | int | decimal.Decimal): The amount to be subtracted.
2440        - desc (str, optional): A description for the transaction. Defaults to an empty string.
2441        - account (AccountName, optional): The account from which the value will be subtracted. Defaults to '1'.
2442        - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD).
2443                                           If not provided, the current timestamp will be used.
2444        - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False.
2445
2446        Returns:
2447        - SubtractReport: A class containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
2448
2449        Raises:
2450        - ValueError: The unscaled_value should be greater than zero.
2451        - ValueError: The created_time_ns should be greater than zero.
2452        - ValueError: The box transaction happened again in the same nanosecond time.
2453        - ValueError: The log transaction happened again in the same nanosecond time.
2454        """
2455        if debug:
2456            print('sub', f'debug={debug}')
2457        if unscaled_value <= 0:
2458            raise ValueError('The unscaled_value should be greater than zero.')
2459        if created_time_ns is None:
2460            created_time_ns = Time.time()
2461        if created_time_ns <= 0:
2462            raise ValueError('The created should be greater than zero.')
2463        no_lock = self.nolock()
2464        lock = self.__lock()
2465        self.__track(0, '', account)
2466        value = self.scale(unscaled_value)
2467        self.__log(value=-value, desc=desc, account=account, created_time_ns=created_time_ns, ref=None, debug=debug)
2468        ids = sorted(self.__vault.account[account].box.keys())
2469        limit = len(ids) + 1
2470        target = value
2471        if debug:
2472            print('ids', ids)
2473        ages = SubtractAges()
2474        for i in range(-1, -limit, -1):
2475            if target == 0:
2476                break
2477            j = ids[i]
2478            if debug:
2479                print('i', i, 'j', j)
2480            rest = self.__vault.account[account].box[j].rest
2481            if rest >= target:
2482                self.__vault.account[account].box[j].rest -= target
2483                self.__step(Action.SUBTRACT, account, ref=j, value=target)
2484                ages.append(SubtractAge(box_ref=j, total=target))
2485                target = 0
2486                break
2487            elif target > rest > 0:
2488                chunk = rest
2489                target -= chunk
2490                self.__vault.account[account].box[j].rest = 0
2491                self.__step(Action.SUBTRACT, account, ref=j, value=chunk)
2492                ages.append(SubtractAge(box_ref=j, total=chunk))
2493        if target > 0:
2494            self.__track(
2495                unscaled_value=self.unscale(-target),
2496                desc=desc,
2497                account=account,
2498                logging=False,
2499                created_time_ns=created_time_ns,
2500            )
2501            ages.append(SubtractAge(box_ref=created_time_ns, total=target))
2502        if no_lock:
2503            assert lock is not None
2504            self.free(lock)
2505        return SubtractReport(
2506            log_ref=created_time_ns,
2507            ages=ages,
2508        )

Subtracts a specified value from an account's balance, 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.

Parameters:

  • unscaled_value (float | int | decimal.Decimal): The amount to be subtracted.
  • desc (str, optional): A description for the transaction. Defaults to an empty string.
  • account (AccountName, optional): The account from which the value will be subtracted. Defaults to '1'.
  • created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, the current timestamp will be used.
  • debug (bool, optional): A flag indicating whether to print debug information. Defaults to False.

Returns:

  • SubtractReport: A class containing the timestamp of the transaction and a list of tuples representing the age of each transaction.

Raises:

  • ValueError: The unscaled_value should be greater than zero.
  • ValueError: The created_time_ns should be greater than zero.
  • ValueError: The box transaction happened again in the same nanosecond time.
  • ValueError: The log transaction happened again in the same nanosecond time.
def transfer( self, unscaled_amount: float | int | decimal.Decimal, from_account: AccountName, to_account: AccountName, desc: str = '', created_time_ns: Optional[Timestamp] = None, debug: bool = False) -> TransferReport:
2510    def transfer(self, unscaled_amount: float | int | decimal.Decimal, from_account: AccountName, to_account: AccountName, desc: str = '',
2511                 created_time_ns: Optional[Timestamp] = None,
2512                 debug: bool = False) -> TransferReport:
2513        """
2514        Transfers a specified value from one account to another.
2515
2516        Parameters:
2517        - unscaled_amount (float | int | decimal.Decimal): The amount to be transferred.
2518        - from_account (AccountName): The account from which the value will be transferred.
2519        - to_account (AccountName): The account to which the value will be transferred.
2520        - desc (str, optional): A description for the transaction. Defaults to an empty string.
2521        - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, the current timestamp will be used.
2522        - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False.
2523
2524        Returns:
2525        - TransferReport: A class of timestamps corresponding to the transactions made during the transfer.
2526
2527        Raises:
2528        - ValueError: Transfer to the same account is forbidden.
2529        - ValueError: The created_time_ns should be greater than zero.
2530        - ValueError: The box transaction happened again in the same nanosecond time.
2531        - ValueError: The log transaction happened again in the same nanosecond time.
2532        """
2533        if debug:
2534            print('transfer', f'debug={debug}')
2535        if from_account == to_account:
2536            raise ValueError(f'Transfer to the same account is forbidden. {to_account}')
2537        if unscaled_amount <= 0:
2538            return []
2539        if created_time_ns is None:
2540            created_time_ns = Time.time()
2541        if created_time_ns <= 0:
2542            raise ValueError('The created should be greater than zero.')
2543        no_lock = self.nolock()
2544        lock = self.__lock()
2545        subtract_report = self.subtract(unscaled_amount, desc, from_account, created_time_ns, debug=debug)
2546        source_exchange = self.exchange(from_account, created_time_ns)
2547        target_exchange = self.exchange(to_account, created_time_ns)
2548
2549        if debug:
2550            print('ages', subtract_report.ages)
2551
2552        transfer_report = TransferReport()
2553        for subtract in subtract_report.ages:
2554            times = TransferTimes()
2555            age = subtract.box_ref
2556            value = subtract.total
2557            assert source_exchange.rate is not None
2558            assert target_exchange.rate is not None
2559            target_amount = int(self.exchange_calc(value, source_exchange.rate, target_exchange.rate))
2560            if debug:
2561                print('target_amount', target_amount)
2562            # Perform the transfer
2563            if self.box_exists(to_account, age):
2564                if debug:
2565                    print('box_exists', age)
2566                capital = self.__vault.account[to_account].box[age].capital
2567                rest = self.__vault.account[to_account].box[age].rest
2568                if debug:
2569                    print(
2570                        f'Transfer(loop) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).')
2571                selected_age = age
2572                if rest + target_amount > capital:
2573                    self.__vault.account[to_account].box[age].capital += target_amount
2574                    selected_age = Time.time()
2575                self.__vault.account[to_account].box[age].rest += target_amount
2576                self.__step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount)
2577                y = self.__log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account,
2578                              created_time_ns=None, ref=None, debug=debug)
2579                times.append(TransferTime(box_ref=age, log_ref=y))
2580                continue
2581            if debug:
2582                print(
2583                    f'Transfer(func) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).')
2584            box_ref = self.__track(
2585                unscaled_value=self.unscale(int(target_amount)),
2586                desc=desc,
2587                account=to_account,
2588                logging=True,
2589                created_time_ns=age,
2590                debug=debug,
2591            )
2592            transfer_report.append(TransferRecord(
2593                box_ref=box_ref,
2594                times=times,
2595            ))
2596        if no_lock:
2597            assert lock is not None
2598            self.free(lock)
2599        return transfer_report

Transfers a specified value from one account to another.

Parameters:

  • unscaled_amount (float | int | decimal.Decimal): The amount to be transferred.
  • from_account (AccountName): The account from which the value will be transferred.
  • to_account (AccountName): The account to which the value will be transferred.
  • desc (str, optional): A description for the transaction. Defaults to an empty string.
  • created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, the current timestamp will be used.
  • debug (bool, optional): A flag indicating whether to print debug information. Defaults to False.

Returns:

  • TransferReport: A class of timestamps corresponding to the transactions made during the transfer.

Raises:

  • ValueError: Transfer to the same account is forbidden.
  • ValueError: The created_time_ns should be greater than zero.
  • ValueError: The box transaction happened again in the same nanosecond time.
  • ValueError: The log transaction happened again in the same nanosecond time.
def check( self, silver_gram_price: float, unscaled_nisab: Union[float, int, decimal.Decimal, NoneType] = None, debug: bool = False, created_time_ns: Optional[Timestamp] = None, cycle: Optional[float] = None) -> ZakatReport:
2601    def check(self,
2602              silver_gram_price: float,
2603              unscaled_nisab: Optional[float | int | decimal.Decimal] = None,
2604              debug: bool = False,
2605              created_time_ns: Optional[Timestamp] = None,
2606              cycle: Optional[float] = None) -> ZakatReport:
2607        """
2608        Check the eligibility for Zakat based on the given parameters.
2609
2610        Parameters:
2611        - silver_gram_price (float): The price of a gram of silver.
2612        - unscaled_nisab (float | int | decimal.Decimal, optional): The minimum amount of wealth required for Zakat.
2613                        If not provided, it will be calculated based on the silver_gram_price.
2614        - debug (bool, optional): Flag to enable debug mode.
2615        - created_time_ns (Timestamp, optional): The current timestamp. If not provided, it will be calculated using Time.time().
2616        - cycle (float, optional): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
2617
2618        Returns:
2619        - ZakatReport: A tuple containing a boolean indicating the eligibility for Zakat,
2620            a list of brief statistics, and a dictionary containing the Zakat plan.
2621        """
2622        if debug:
2623            print('check', f'debug={debug}')
2624        if created_time_ns is None:
2625            created_time_ns = Time.time()
2626        if cycle is None:
2627            cycle = ZakatTracker.TimeCycle()
2628        if unscaled_nisab is None:
2629            unscaled_nisab = ZakatTracker.Nisab(silver_gram_price)
2630        nisab = self.scale(unscaled_nisab)
2631        plan = ZakatPlan()
2632        statistics = ZakatReportStatistics()
2633        below_nisab = 0
2634        valid = False
2635        if debug:
2636            print('exchanges', self.exchanges())
2637        for x in self.__vault.account:
2638            if not self.zakatable(x):
2639                continue
2640            _box = self.__vault.account[x].box
2641            _log = self.__vault.account[x].log
2642            limit = len(_box) + 1
2643            ids = sorted(self.__vault.account[x].box.keys())
2644            for i in range(-1, -limit, -1):
2645                j = ids[i]
2646                rest = float(_box[j].rest)
2647                if rest <= 0:
2648                    continue
2649                exchange = self.exchange(x, created_time_ns=Time.time())
2650                assert exchange.rate is not None
2651                rest = ZakatTracker.exchange_calc(rest, float(exchange.rate), 1)
2652                statistics.overall_wealth += rest
2653                epoch = (created_time_ns - j) / cycle
2654                if debug:
2655                    print(f'Epoch: {epoch}', _box[j])
2656                if _box[j].last > 0:
2657                    epoch = (created_time_ns - _box[j].last) / cycle
2658                if debug:
2659                    print(f'Epoch: {epoch}')
2660                epoch = math.floor(epoch)
2661                if debug:
2662                    print(f'Epoch: {epoch}', type(epoch), epoch == 0, 1 - epoch, epoch)
2663                if epoch == 0:
2664                    continue
2665                if debug:
2666                    print('Epoch - PASSED')
2667                statistics.zakatable_transactions_balance += rest
2668                is_nisab = rest >= nisab
2669                total = 0
2670                if is_nisab:
2671                    for _ in range(epoch):
2672                        total += ZakatTracker.ZakatCut(float(rest) - float(total))
2673                    valid = total > 0
2674                elif rest > 0:
2675                    below_nisab += rest
2676                    total = ZakatTracker.ZakatCut(float(rest))
2677                if total > 0:
2678                    if x not in plan:
2679                        plan[x] = []
2680                    statistics.zakat_cut_balances += total
2681                    plan[x].append(BoxPlan(
2682                        below_nisab=not is_nisab,
2683                        total=total,
2684                        count=epoch,
2685                        ref=j,
2686                        box=_box[j],
2687                        log=_log[j],
2688                        exchange=exchange,
2689                    ))
2690        valid = valid or below_nisab >= nisab
2691        if debug:
2692            print(f'below_nisab({below_nisab}) >= nisab({nisab})')
2693        return ZakatReport(
2694            valid=valid,
2695            statistics=statistics,
2696            plan=plan,
2697        )

Check the eligibility for Zakat based on the given parameters.

Parameters:

  • silver_gram_price (float): The price of a gram of silver.
  • unscaled_nisab (float | int | decimal.Decimal, optional): The minimum amount of wealth required for Zakat. If not provided, it will be calculated based on the silver_gram_price.
  • debug (bool, optional): Flag to enable debug mode.
  • created_time_ns (Timestamp, optional): The current timestamp. If not provided, it will be calculated using Time.time().
  • cycle (float, optional): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().

Returns:

  • ZakatReport: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics, and a dictionary containing the Zakat plan.
def build_payment_parts( self, scaled_demand: int, positive_only: bool = True) -> PaymentParts:
2699    def build_payment_parts(self, scaled_demand: int, positive_only: bool = True) -> PaymentParts:
2700        """
2701        Build payment parts for the Zakat distribution.
2702
2703        Parameters:
2704        - scaled_demand (int): The total demand for payment in local currency.
2705        - positive_only (bool, optional): If True, only consider accounts with positive balance. Default is True.
2706
2707        Returns:
2708        - PaymentParts: A dictionary containing the payment parts for each account. The dictionary has the following structure:
2709        {
2710            'account': {
2711                'account_id': {'balance': float, 'rate': float, 'part': float},
2712                ...
2713            },
2714            'exceed': bool,
2715            'demand': int,
2716            'total': float,
2717        }
2718        """
2719        total = 0.0
2720        parts = PaymentParts(
2721            account={},
2722            exceed=False,
2723            demand=int(round(scaled_demand)),
2724            total=0,
2725        )
2726        for x, y in self.accounts().items():
2727            if positive_only and y <= 0:
2728                continue
2729            total += float(y)
2730            exchange = self.exchange(x)
2731            parts.account[x] = AccountPaymentPart(balance=y, rate=exchange.rate, part=0)
2732        parts.total = total
2733        return parts

Build payment parts for the Zakat distribution.

Parameters:

  • scaled_demand (int): The total demand for payment in local currency.
  • positive_only (bool, optional): If True, only consider accounts with positive balance. Default is True.

Returns:

  • PaymentParts: 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': int, 'total': float, }
@staticmethod
def check_payment_parts(parts: PaymentParts, debug: bool = False) -> int:
2735    @staticmethod
2736    def check_payment_parts(parts: PaymentParts, debug: bool = False) -> int:
2737        """
2738        Checks the validity of payment parts.
2739
2740        Parameters:
2741        - parts (dict[str, PaymentParts): A dictionary containing payment parts information.
2742        - debug (bool, optional): Flag to enable debug mode.
2743
2744        Returns:
2745        - int: Returns 0 if the payment parts are valid, otherwise returns the error code.
2746
2747        Error Codes:
2748        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
2749        2: 'balance', 'rate' or 'part' key is missing in parts['account'][x].
2750        3: 'part' value in parts['account'][x] is less than 0.
2751        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
2752        5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
2753        6: The sum of 'part' values in parts['account'] does not match with 'demand' value.
2754        """
2755        if debug:
2756            print('check_payment_parts', f'debug={debug}')
2757        # for i in ['demand', 'account', 'total', 'exceed']:
2758        #     if i not in parts:
2759        #         return 1
2760        exceed = parts.exceed
2761        # for j in ['balance', 'rate', 'part']:
2762        #     if j not in parts.account[x]:
2763        #         return 2
2764        for x in parts.account:
2765            if parts.account[x].part < 0:
2766                return 3
2767            if not exceed and parts.account[x].balance <= 0:
2768                return 4
2769        demand = parts.demand
2770        z = 0.0
2771        for _, y in parts.account.items():
2772            if not exceed and y.part > y.balance:
2773                return 5
2774            z += ZakatTracker.exchange_calc(y.part, y.rate, 1.0)
2775        z = round(z, 2)
2776        demand = round(demand, 2)
2777        if debug:
2778            print('check_payment_parts', f'z = {z}, demand = {demand}')
2779            print('check_payment_parts', type(z), type(demand))
2780            print('check_payment_parts', z != demand)
2781            print('check_payment_parts', str(z) != str(demand))
2782        if z != demand and str(z) != str(demand):
2783            return 6
2784        return 0

Checks the validity of payment parts.

Parameters:

  • parts (dict[str, PaymentParts): A dictionary containing payment parts information.
  • debug (bool, optional): 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.

def zakat( self, report: ZakatReport, parts: Optional[PaymentParts] = None, debug: bool = False) -> bool:
2786    def zakat(self, report: ZakatReport,
2787        parts: Optional[PaymentParts] = None, debug: bool = False) -> bool:
2788        """
2789        Perform Zakat calculation based on the given report and optional parts.
2790
2791        Parameters:
2792        - report (ZakatReport): A dataclass containing the validity of the report, the report data, and the zakat plan.
2793        - parts (PaymentParts, optional): A dictionary containing the payment parts for the zakat.
2794        - debug (bool, optional): A flag indicating whether to print debug information.
2795
2796        Returns:
2797        - bool: True if the zakat calculation is successful, False otherwise.
2798        """
2799        if debug:
2800            print('zakat', f'debug={debug}')
2801        if not report.valid:
2802            return report.valid
2803        parts_exist = parts is not None
2804        if parts_exist:
2805            if self.check_payment_parts(parts, debug=debug) != 0:
2806                return False
2807        if debug:
2808            print('######### zakat #######')
2809            print('parts_exist', parts_exist)
2810        no_lock = self.nolock()
2811        lock = self.__lock()
2812        report_time = Time.time()
2813        self.__vault.report[report_time] = report
2814        self.__step(Action.REPORT, ref=report_time)
2815        created_time_ns = Time.time()
2816        for x in report.plan:
2817            target_exchange = self.exchange(x)
2818            if debug:
2819                print(report.plan[x])
2820                print('-------------')
2821                print(self.__vault.account[x].box)
2822            if debug:
2823                print('plan[x]', report.plan[x])
2824            for plan in report.plan[x]:
2825                j = plan.ref
2826                if debug:
2827                    print('j', j)
2828                assert j
2829                self.__step(Action.ZAKAT, account=x, ref=j, value=self.__vault.account[x].box[j].last,
2830                           key='last',
2831                           math_operation=MathOperation.EQUAL)
2832                self.__vault.account[x].box[j].last = created_time_ns
2833                assert target_exchange.rate is not None
2834                amount = ZakatTracker.exchange_calc(float(plan.total), 1, float(target_exchange.rate))
2835                self.__vault.account[x].box[j].total += amount
2836                self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='total',
2837                           math_operation=MathOperation.ADDITION)
2838                self.__vault.account[x].box[j].count += plan.count
2839                self.__step(Action.ZAKAT, account=x, ref=j, value=plan.count, key='count',
2840                           math_operation=MathOperation.ADDITION)
2841                if not parts_exist:
2842                    try:
2843                        self.__vault.account[x].box[j].rest -= amount
2844                    except TypeError:
2845                        self.__vault.account[x].box[j].rest -= decimal.Decimal(amount)
2846                    # self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest',
2847                    #            math_operation=MathOperation.SUBTRACTION)
2848                    self.__log(-float(amount), desc='zakat-زكاة', account=x, created_time_ns=None, ref=j, debug=debug)
2849        if parts_exist:
2850            for account, part in parts.account.items():
2851                if part.part == 0:
2852                    continue
2853                if debug:
2854                    print('zakat-part', account, part.rate)
2855                target_exchange = self.exchange(account)
2856                assert target_exchange.rate is not None
2857                amount = ZakatTracker.exchange_calc(part.part, part.rate, target_exchange.rate)
2858                self.subtract(
2859                    unscaled_value=self.unscale(int(amount)),
2860                    desc='zakat-part-دفعة-زكاة',
2861                    account=account,
2862                    debug=debug,
2863                )
2864        if no_lock:
2865            assert lock is not None
2866            self.free(lock)
2867        return True

Perform Zakat calculation based on the given report and optional parts.

Parameters:

  • report (ZakatReport): A dataclass containing the validity of the report, the report data, and the zakat plan.
  • parts (PaymentParts, optional): A dictionary containing the payment parts for the zakat.
  • debug (bool, optional): A flag indicating whether to print debug information.

Returns:

  • bool: True if the zakat calculation is successful, False otherwise.
@staticmethod
def split_at_last_symbol(data: str, symbol: str) -> tuple[str, str]:
2869    @staticmethod
2870    def split_at_last_symbol(data: str, symbol: str) -> tuple[str, str]:
2871        """Splits a string at the last occurrence of a given symbol.
2872    
2873        Parameters:
2874        - data (str): The input string.
2875        - symbol (str): The symbol to split at.
2876    
2877        Returns:
2878        - tuple[str, str]: A tuple containing two strings, the part before the last symbol and
2879            the part after the last symbol. If the symbol is not found, returns (data, "").
2880        """
2881        last_symbol_index = data.rfind(symbol)
2882    
2883        if last_symbol_index != -1:
2884            before_symbol = data[:last_symbol_index]
2885            after_symbol = data[last_symbol_index + len(symbol):]
2886            return before_symbol, after_symbol
2887        return data, ""

Splits a string at the last occurrence of a given symbol.

Parameters:

  • data (str): The input string.
  • symbol (str): The symbol to split at.

Returns:

  • tuple[str, str]: A tuple containing two strings, the part before the last symbol and the part after the last symbol. If the symbol is not found, returns (data, "").
def save(self, path: Optional[str] = None, hash_required: bool = True) -> bool:
2889    def save(self, path: Optional[str] = None, hash_required: bool = True) -> bool:
2890        """
2891        Saves the ZakatTracker's current state to a json file.
2892
2893        This method serializes the internal data (`__vault`).
2894
2895        Parameters:
2896        - path (str, optional): File path for saving. Defaults to a predefined location.
2897        - hash_required (bool, optional): Whether to add the data integrity using a hash. Defaults to True.
2898
2899        Returns:
2900        - bool: True if the save operation is successful, False otherwise.
2901        """
2902        if path is None:
2903            path = self.path()
2904        # first save in tmp file
2905        temp = f'{path}.tmp'
2906        try:
2907            with open(temp, 'w', encoding='utf-8') as stream:
2908                data = json.dumps(self.__vault, cls=JSONEncoder)
2909                stream.write(data)
2910                if hash_required:
2911                    hashed = self.hash_data(data.encode())
2912                    stream.write(f'//{hashed}')
2913            # then move tmp file to original location
2914            shutil.move(temp, path)
2915            return True
2916        except (IOError, OSError) as e:
2917            print(f'Error saving file: {e}')
2918            if os.path.exists(temp):
2919                os.remove(temp)
2920            return False

Saves the ZakatTracker's current state to a json file.

This method serializes the internal data (__vault).

Parameters:

  • path (str, optional): File path for saving. Defaults to a predefined location.
  • hash_required (bool, optional): Whether to add the data integrity using a hash. Defaults to True.

Returns:

  • bool: True if the save operation is successful, False otherwise.
@staticmethod
def load_vault_from_json(json_string: str) -> Vault:
2922    @staticmethod
2923    def load_vault_from_json(json_string: str) -> Vault:
2924        """Loads a Vault dataclass from a JSON string."""
2925        data = json.loads(json_string)
2926
2927        vault = Vault()
2928
2929        # Load Accounts
2930        for account_name, account_data in data.get("account", {}).items():
2931            account_name = AccountName(account_name)
2932            box_data = account_data.get('box', {})
2933            box = {Timestamp(ts): Box(**box_data[str(ts)]) for ts in box_data}
2934
2935            log_data = account_data.get('log', {})
2936            log = {Timestamp(ts): Log(
2937                value=log_data[str(ts)]['value'],
2938                desc=log_data[str(ts)]['desc'],
2939                ref=Timestamp(log_data[str(ts)].get('ref')) if log_data[str(ts)].get('ref') is not None else None,
2940                file={Timestamp(ft): fv for ft, fv in log_data[str(ts)].get('file', {}).items()}
2941            ) for ts in log_data}
2942
2943            vault.account[account_name] = Account(
2944                balance=account_data["balance"],
2945                created=Timestamp(account_data["created"]),
2946                box=box,
2947                count=account_data.get("count", 0),
2948                log=log,
2949                hide=account_data.get("hide", False),
2950                zakatable=account_data.get("zakatable", True),
2951            )
2952
2953        # Load Exchanges
2954        for account_name, exchange_data in data.get("exchange", {}).items():
2955            account_name = AccountName(account_name)
2956            vault.exchange[account_name] = {}
2957            for timestamp, exchange_details in exchange_data.items():
2958                vault.exchange[account_name][Timestamp(timestamp)] = Exchange(
2959                    rate=exchange_details.get("rate"),
2960                    description=exchange_details.get("description"),
2961                    time=Timestamp(exchange_details.get("time")) if exchange_details.get("time") is not None else None
2962                )
2963
2964        # Load History
2965        for timestamp, history_list in data.get("history", {}).items():
2966            vault.history[Timestamp(timestamp)] = []
2967            for history_data in history_list:
2968                vault.history[Timestamp(timestamp)].append(History(
2969                    action=Action(history_data["action"]),
2970                    account=AccountName(history_data["account"]) if history_data.get("account") is not None else None,
2971                    ref=Timestamp(history_data.get("ref")) if history_data.get("ref") is not None else None,
2972                    file=Timestamp(history_data.get("file")) if history_data.get("file") is not None else None,
2973                    key=history_data.get("key"),
2974                    value=history_data.get("value"),
2975                    math=MathOperation(history_data.get("math")) if history_data.get("math") is not None else None
2976                ))
2977
2978        # Load Lock
2979        vault.lock = Timestamp(data["lock"]) if data.get("lock") is not None else None
2980
2981        # Load Report
2982        for timestamp, report_data in data.get("report", {}).items():
2983            zakat_plan = ZakatPlan()
2984            for account_name, box_plans in report_data.get("plan", {}).items():
2985                account_name = AccountName(account_name)
2986                zakat_plan[account_name] = []
2987                for box_plan_data in box_plans:
2988                    zakat_plan[account_name].append(BoxPlan(
2989                        box=Box(**box_plan_data["box"]),
2990                        log=Log(**box_plan_data["log"]),
2991                        exchange=Exchange(**box_plan_data["exchange"]),
2992                        below_nisab=box_plan_data["below_nisab"],
2993                        total=box_plan_data["total"],
2994                        count=box_plan_data["count"],
2995                        ref=Timestamp(box_plan_data["ref"])
2996                    ))
2997
2998            vault.report[Timestamp(timestamp)] = ZakatReport(
2999                valid=report_data["valid"],
3000                statistics=ZakatReportStatistics(**report_data["statistics"]),
3001                plan=zakat_plan
3002            )
3003
3004        return vault

Loads a Vault dataclass from a JSON string.

def load( self, path: Optional[str] = None, hash_required: bool = True, debug: bool = False) -> bool:
3006    def load(self, path: Optional[str] = None, hash_required: bool = True, debug: bool = False) -> bool:
3007        """
3008        Load the current state of the ZakatTracker object from a json file.
3009
3010        Parameters:
3011        - path (str, optional): The path where the json file is located. If not provided, it will use the default path.
3012        - hash_required (bool, optional): Whether to verify the data integrity using a hash. Defaults to True.
3013        - debug (bool, optional): Flag to enable debug mode.
3014
3015        Returns:
3016        - bool: True if the load operation is successful, False otherwise.
3017        """
3018        if path is None:
3019            path = self.path()
3020        try:
3021            if os.path.exists(path):
3022                with open(path, 'r', encoding='utf-8') as stream:
3023                    file = stream.read()
3024                    data, hashed = self.split_at_last_symbol(file, '//')
3025                    if hash_required:
3026                        assert hashed
3027                        if debug:
3028                            print('[debug-load]', hashed)
3029                        new_hash = self.hash_data(data.encode())
3030                        if debug:
3031                            print('[debug-load]', new_hash)
3032                        assert hashed == new_hash, "Hash verification failed. File may be corrupted."
3033                    self.__vault = self.load_vault_from_json(data)
3034                return True
3035            else:
3036                print(f'File not found: {path}')
3037                return False
3038        except (IOError, OSError) as e:
3039            print(f'Error loading file: {e}')
3040            return False

Load the current state of the ZakatTracker object from a json file.

Parameters:

  • path (str, optional): The path where the json file is located. If not provided, it will use the default path.
  • hash_required (bool, optional): Whether to verify the data integrity using a hash. Defaults to True.
  • debug (bool, optional): Flag to enable debug mode.

Returns:

  • bool: True if the load operation is successful, False otherwise.
def import_csv_cache_path(self):
3042    def import_csv_cache_path(self):
3043        """
3044        Generates the cache file path for imported CSV data.
3045
3046        This function constructs the file path where cached data from CSV imports
3047        will be stored. The cache file is a json file (.json extension) appended
3048        to the base path of the object.
3049
3050        Parameters:
3051        None
3052
3053        Returns:
3054        - str: The full path to the import CSV cache file.
3055
3056        Example:
3057        ```bash
3058        >>> obj = ZakatTracker('/data/reports')
3059        >>> obj.import_csv_cache_path()
3060        '/data/reports.import_csv.json'
3061        ```
3062        """
3063        path = str(self.path())
3064        ext = self.ext()
3065        ext_len = len(ext)
3066        if path.endswith(f'.{ext}'):
3067            path = path[:-ext_len - 1]
3068        _, filename = os.path.split(path + f'.import_csv.{ext}')
3069        return self.base_path(filename)

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 json file (.json extension) appended to the base path of the object.

Parameters: None

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.json'
def import_csv( self, path: str = 'file.csv', scale_decimal_places: int = 0, debug: bool = False) -> tuple:
3071    def import_csv(self, path: str = 'file.csv', scale_decimal_places: int = 0, debug: bool = False) -> tuple:
3072        """
3073        The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system.
3074
3075        Parameters:
3076        - path (str, optional): The path to the CSV file. Default is 'file.csv'.
3077        - scale_decimal_places (int, optional): The number of decimal places to scale the value. Default is 0.
3078        - debug (bool, optional): A flag indicating whether to print debug information.
3079
3080        Returns:
3081        - tuple: A tuple containing the number of transactions created, the number of transactions found in the cache,
3082                and a dictionary of bad transactions.
3083
3084        Notes:
3085        * Currency Pair Assumption: This function assumes that the exchange rates stored for each account
3086                                    are appropriate for the currency pairs involved in the conversions.
3087        * The exchange rate for each account is based on the last encountered transaction rate that is not equal
3088            to 1.0 or the previous rate for that account.
3089        * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent
3090            transactions of the same account within the whole imported and existing dataset when doing `check` and
3091            `zakat` operations.
3092
3093        Example:
3094            The CSV file should have the following format, rate is optional per transaction:
3095            account, desc, value, date, rate
3096            For example:
3097            safe-45, 'Some text', 34872, 1988-06-30 00:00:00, 1
3098        """
3099        if debug:
3100            print('import_csv', f'debug={debug}')
3101        cache: list[int] = []
3102        try:
3103            with open(self.import_csv_cache_path(), 'r', encoding='utf-8') as stream:
3104                cache = json.load(stream)
3105        except:
3106            pass
3107        date_formats = [
3108            '%Y-%m-%d %H:%M:%S',
3109            '%Y-%m-%dT%H:%M:%S',
3110            '%Y-%m-%dT%H%M%S.%f',
3111            '%Y-%m-%d',
3112        ]
3113        created, found, bad = 0, 0, {}
3114        data: dict[int, list] = {}
3115        with open(path, newline='', encoding='utf-8') as f:
3116            i = 0
3117            for row in csv.reader(f, delimiter=','):
3118                i += 1
3119                hashed = hash(tuple(row))
3120                if hashed in cache:
3121                    found += 1
3122                    continue
3123                account = row[0]
3124                desc = row[1]
3125                value = float(row[2])
3126                rate = 1.0
3127                if row[4:5]:  # Empty list if index is out of range
3128                    rate = float(row[4])
3129                date: int = 0
3130                for time_format in date_formats:
3131                    try:
3132                        date = Time.time(datetime.datetime.strptime(row[3], time_format))
3133                        break
3134                    except:
3135                        pass
3136                if date <= 0:
3137                    bad[i] = row + ['invalid date']
3138                if value == 0:
3139                    bad[i] = row + ['invalid value']
3140                    continue
3141                if date not in data:
3142                    data[date] = []
3143                data[date].append((i, account, desc, value, date, rate, hashed))
3144
3145        if debug:
3146            print('import_csv', len(data))
3147
3148        if bad:
3149            return created, found, bad
3150
3151        no_lock = self.nolock()
3152        lock = self.__lock()
3153        for date, rows in sorted(data.items()):
3154            try:
3155                len_rows = len(rows)
3156                if len_rows == 1:
3157                    (_, account, desc, unscaled_value, date, rate, hashed) = rows[0]
3158                    value = self.unscale(
3159                        unscaled_value,
3160                        decimal_places=scale_decimal_places,
3161                    ) if scale_decimal_places > 0 else unscaled_value
3162                    if rate > 0:
3163                        self.exchange(account=account, created_time_ns=date, rate=rate)
3164                    if value > 0:
3165                        self.track(unscaled_value=value, desc=desc, account=account, created_time_ns=date)
3166                    elif value < 0:
3167                        self.subtract(unscaled_value=-value, desc=desc, account=account, created_time_ns=date)
3168                    created += 1
3169                    cache.append(hashed)
3170                    continue
3171                if debug:
3172                    print('-- Duplicated time detected', date, 'len', len_rows)
3173                    print(rows)
3174                    print('---------------------------------')
3175                # If records are found at the same time with different accounts in the same amount
3176                # (one positive and the other negative), this indicates it is a transfer.
3177                if len_rows != 2:
3178                    raise Exception(f'more than two transactions({len_rows}) at the same time')
3179                (i, account1, desc1, unscaled_value1, date1, rate1, _) = rows[0]
3180                (j, account2, desc2, unscaled_value2, date2, rate2, _) = rows[1]
3181                if account1 == account2 or desc1 != desc2 or abs(unscaled_value1) != abs(
3182                        unscaled_value2) or date1 != date2:
3183                    raise Exception('invalid transfer')
3184                if rate1 > 0:
3185                    self.exchange(account1, created_time_ns=date1, rate=rate1)
3186                if rate2 > 0:
3187                    self.exchange(account2, created_time_ns=date2, rate=rate2)
3188                value1 = self.unscale(
3189                    unscaled_value1,
3190                    decimal_places=scale_decimal_places,
3191                ) if scale_decimal_places > 0 else unscaled_value1
3192                value2 = self.unscale(
3193                    unscaled_value2,
3194                    decimal_places=scale_decimal_places,
3195                ) if scale_decimal_places > 0 else unscaled_value2
3196                values = {
3197                    value1: account1,
3198                    value2: account2,
3199                }
3200                self.transfer(
3201                    unscaled_amount=abs(value1),
3202                    from_account=values[min(values.keys())],
3203                    to_account=values[max(values.keys())],
3204                    desc=desc1,
3205                    created_time_ns=date1,
3206                )
3207            except Exception as e:
3208                for (i, account, desc, value, date, rate, _) in rows:
3209                    bad[i] = (account, desc, value, date, rate, e)
3210                break
3211        with open(self.import_csv_cache_path(), 'w', encoding='utf-8') as stream:
3212            stream.write(json.dumps(cache))
3213        if no_lock:
3214            assert lock is not None
3215            self.free(lock)
3216        y = created, found, bad
3217        if debug:
3218            debug_path = f'{self.import_csv_cache_path()}.debug.json'
3219            with open(debug_path, 'w', encoding='utf-8') as file:
3220                json.dump(y, file, indent=4, cls=JSONEncoder)
3221                print(f'generated debug report @ `{debug_path}`...')
3222        return y

The function reads the CSV file, checks for duplicate transactions, and creates the transactions in the system.

Parameters:

  • path (str, optional): The path to the CSV file. Default is 'file.csv'.
  • scale_decimal_places (int, optional): The number of decimal places to scale the value. Default is 0.
  • debug (bool, optional): 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: 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

@staticmethod
def human_readable_size(size: float, decimal_places: int = 2) -> str:
3228    @staticmethod
3229    def human_readable_size(size: float, decimal_places: int = 2) -> str:
3230        """
3231        Converts a size in bytes to a human-readable format (e.g., KB, MB, GB).
3232
3233        This function iterates through progressively larger units of information
3234        (B, KB, MB, GB, etc.) and divides the input size until it fits within a
3235        range that can be expressed with a reasonable number before the unit.
3236
3237        Parameters:
3238        - size (float): The size in bytes to convert.
3239        - decimal_places (int, optional): The number of decimal places to display
3240            in the result. Defaults to 2.
3241
3242        Returns:
3243        - str: A string representation of the size in a human-readable format,
3244            rounded to the specified number of decimal places. For example:
3245                - '1.50 KB' (1536 bytes)
3246                - '23.00 MB' (24117248 bytes)
3247                - '1.23 GB' (1325899906 bytes)
3248        """
3249        if type(size) not in (float, int):
3250            raise TypeError('size must be a float or integer')
3251        if type(decimal_places) != int:
3252            raise TypeError('decimal_places must be an integer')
3253        for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']:
3254            if size < 1024.0:
3255                break
3256            size /= 1024.0
3257        return f'{size:.{decimal_places}f} {unit}'

Converts a size in bytes to a human-readable format (e.g., KB, MB, GB).

This function iterates through progressively larger units of information (B, KB, MB, GB, etc.) and divides the input size until it fits within a range that can be expressed with a reasonable number before the unit.

Parameters:

  • size (float): The size in bytes to convert.
  • decimal_places (int, optional): The number of decimal places to display in the result. Defaults to 2.

Returns:

  • str: A string representation of the size in a human-readable format, rounded to the specified number of decimal places. For example: - '1.50 KB' (1536 bytes) - '23.00 MB' (24117248 bytes) - '1.23 GB' (1325899906 bytes)
@staticmethod
def get_dict_size(obj: dict, seen: Optional[set] = None) -> float:
3259    @staticmethod
3260    def get_dict_size(obj: dict, seen: Optional[set] = None) -> float:
3261        """
3262        Recursively calculates the approximate memory size of a dictionary and its contents in bytes.
3263
3264        This function traverses the dictionary structure, accounting for the size of keys, values,
3265        and any nested objects. It handles various data types commonly found in dictionaries
3266        (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case
3267        of circular references.
3268
3269        Parameters:
3270        - obj (dict): The dictionary whose size is to be calculated.
3271        - seen (set, optional): A set used internally to track visited objects
3272                             and avoid circular references. Defaults to None.
3273
3274        Returns:
3275         - float: An approximate size of the dictionary and its contents in bytes.
3276
3277        Notes:
3278        - This function is a method of the `ZakatTracker` class and is likely used to
3279          estimate the memory footprint of data structures relevant to Zakat calculations.
3280        - The size calculation is approximate as it relies on `sys.getsizeof()`, which might
3281          not account for all memory overhead depending on the Python implementation.
3282        - Circular references are handled to prevent infinite recursion.
3283        - Basic numeric types (int, float, complex) are assumed to have fixed sizes.
3284        - String sizes are estimated based on character length and encoding.
3285        """
3286        size = 0
3287        if seen is None:
3288            seen = set()
3289
3290        obj_id = id(obj)
3291        if obj_id in seen:
3292            return 0
3293
3294        seen.add(obj_id)
3295        size += sys.getsizeof(obj)
3296
3297        if isinstance(obj, dict):
3298            for k, v in obj.items():
3299                size += ZakatTracker.get_dict_size(k, seen)
3300                size += ZakatTracker.get_dict_size(v, seen)
3301        elif isinstance(obj, (list, tuple, set, frozenset)):
3302            for item in obj:
3303                size += ZakatTracker.get_dict_size(item, seen)
3304        elif isinstance(obj, (int, float, complex)):  # Handle numbers
3305            pass  # Basic numbers have a fixed size, so nothing to add here
3306        elif isinstance(obj, str):  # Handle strings
3307            size += len(obj) * sys.getsizeof(str().encode())  # Size per character in bytes
3308        return size

Recursively calculates the approximate memory size of a dictionary and its contents in bytes.

This function traverses the dictionary structure, accounting for the size of keys, values, and any nested objects. It handles various data types commonly found in dictionaries (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case of circular references.

Parameters:

  • obj (dict): The dictionary whose size is to be calculated.
  • seen (set, optional): A set used internally to track visited objects and avoid circular references. Defaults to None.

Returns:

  • float: An approximate size of the dictionary and its contents in bytes.

Notes:

  • This function is a method of the ZakatTracker class and is likely used to estimate the memory footprint of data structures relevant to Zakat calculations.
  • The size calculation is approximate as it relies on sys.getsizeof(), which might not account for all memory overhead depending on the Python implementation.
  • Circular references are handled to prevent infinite recursion.
  • Basic numeric types (int, float, complex) are assumed to have fixed sizes.
  • String sizes are estimated based on character length and encoding.
@staticmethod
def day_to_time(day: int, month: int = 6, year: int = 2024) -> int:
3310    @staticmethod
3311    def day_to_time(day: int, month: int = 6, year: int = 2024) -> int:  # افتراض أن الشهر هو يونيو والسنة 2024
3312        """
3313        Convert a specific day, month, and year into a timestamp.
3314
3315        Parameters:
3316        - day (int): The day of the month.
3317        - month (int, optional): The month of the year. Default is 6 (June).
3318        - year (int, optional): The year. Default is 2024.
3319
3320        Returns:
3321        - int: The timestamp representing the given day, month, and year.
3322
3323        Note:
3324        - This method assumes the default month and year if not provided.
3325        """
3326        return Time.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, optional): The month of the year. Default is 6 (June).
  • year (int, optional): 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.
@staticmethod
def generate_random_date( start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
3328    @staticmethod
3329    def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
3330        """
3331        Generate a random date between two given dates.
3332
3333        Parameters:
3334        - start_date (datetime.datetime): The start date from which to generate a random date.
3335        - end_date (datetime.datetime): The end date until which to generate a random date.
3336
3337        Returns:
3338        - datetime.datetime: A random date between the start_date and end_date.
3339        """
3340        time_between_dates = end_date - start_date
3341        days_between_dates = time_between_dates.days
3342        random_number_of_days = random.randrange(days_between_dates)
3343        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.
@staticmethod
def generate_random_csv_file( path: str = 'data.csv', count: int = 1000, with_rate: bool = False, debug: bool = False) -> int:
3345    @staticmethod
3346    def generate_random_csv_file(path: str = 'data.csv', count: int = 1_000, with_rate: bool = False,
3347                                 debug: bool = False) -> int:
3348        """
3349        Generate a random CSV file with specified parameters.
3350        The function generates a CSV file at the specified path with the given count of rows.
3351        Each row contains a randomly generated account, description, value, and date.
3352        The value is randomly generated between 1000 and 100000,
3353        and the date is randomly generated between 1950-01-01 and 2023-12-31.
3354        If the row number is not divisible by 13, the value is multiplied by -1.
3355
3356        Parameters:
3357        - path (str, optional): The path where the CSV file will be saved. Default is 'data.csv'.
3358        - count (int, optional): The number of rows to generate in the CSV file. Default is 1000.
3359        - with_rate (bool, optional): If True, a random rate between 1.2% and 12% is added. Default is False.
3360        - debug (bool, optional): A flag indicating whether to print debug information.
3361
3362        Returns:
3363        None
3364        """
3365        if debug:
3366            print('generate_random_csv_file', f'debug={debug}')
3367        i = 0
3368        with open(path, 'w', newline='', encoding='utf-8') as csvfile:
3369            writer = csv.writer(csvfile)
3370            for i in range(count):
3371                account = f'acc-{random.randint(1, count)}'
3372                desc = f'Some text {random.randint(1, count)}'
3373                value = random.randint(1000, 100000)
3374                date = ZakatTracker.generate_random_date(datetime.datetime(1000, 1, 1),
3375                                                         datetime.datetime(2023, 12, 31)).strftime('%Y-%m-%d %H:%M:%S')
3376                if not i % 13 == 0:
3377                    value *= -1
3378                row = [account, desc, value, date]
3379                if with_rate:
3380                    rate = random.randint(1, 100) * 0.12
3381                    if debug:
3382                        print('before-append', row)
3383                    row.append(rate)
3384                    if debug:
3385                        print('after-append', row)
3386                writer.writerow(row)
3387                i = i + 1
3388        return i

Generate a random CSV file with specified parameters. 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.

Parameters:

  • path (str, optional): The path where the CSV file will be saved. Default is 'data.csv'.
  • count (int, optional): The number of rows to generate in the CSV file. Default is 1000.
  • with_rate (bool, optional): If True, a random rate between 1.2% and 12% is added. Default is False.
  • debug (bool, optional): A flag indicating whether to print debug information.

Returns: None

@staticmethod
def create_random_list(max_sum: int, min_value: int = 0, max_value: int = 10):
3390    @staticmethod
3391    def create_random_list(max_sum: int, min_value: int = 0, max_value: int = 10):
3392        """
3393        Creates a list of random integers whose sum does not exceed the specified maximum.
3394
3395        Parameters:
3396        - max_sum (int): The maximum allowed sum of the list elements.
3397        - min_value (int, optional): The minimum possible value for an element (inclusive).
3398        - max_value (int, optional): The maximum possible value for an element (inclusive).
3399
3400        Returns:
3401        - A list of random integers.
3402        """
3403        result = []
3404        current_sum = 0
3405
3406        while current_sum < max_sum:
3407            # Calculate the remaining space for the next element
3408            remaining_sum = max_sum - current_sum
3409            # Determine the maximum possible value for the next element
3410            next_max_value = min(remaining_sum, max_value)
3411            # Generate a random element within the allowed range
3412            next_element = random.randint(min_value, next_max_value)
3413            result.append(next_element)
3414            current_sum += next_element
3415
3416        return result

Creates a list of random integers whose sum does not exceed the specified maximum.

Parameters:

  • max_sum (int): The maximum allowed sum of the list elements.
  • min_value (int, optional): The minimum possible value for an element (inclusive).
  • max_value (int, optional): The maximum possible value for an element (inclusive).

Returns:

  • A list of random integers.
def test(self, debug: bool = False) -> bool:
3682    def test(self, debug: bool = False) -> bool:
3683        if debug:
3684            print('test', f'debug={debug}')
3685        try:
3686
3687            self._test_core(True, debug)
3688            self._test_core(False, debug)
3689
3690            assert self.__history()
3691
3692            # Not allowed for duplicate transactions in the same account and time
3693
3694            created = Time.time()
3695            self.track(100, 'test-1', 'same', True, created)
3696            failed = False
3697            try:
3698                self.track(50, 'test-1', 'same', True, created)
3699            except:
3700                failed = True
3701            assert failed is True
3702
3703            self.reset()
3704
3705            # Same account transfer
3706            for x in [1, 'a', True, 1.8, None]:
3707                failed = False
3708                try:
3709                    self.transfer(1, x, x, 'same-account', debug=debug)
3710                except:
3711                    failed = True
3712                assert failed is True
3713
3714            # Always preserve box age during transfer
3715
3716            series: list[tuple[int, int]] = [
3717                (30, 4),
3718                (60, 3),
3719                (90, 2),
3720            ]
3721            case = {
3722                3000: {
3723                    'series': series,
3724                    'rest': 15000,
3725                },
3726                6000: {
3727                    'series': series,
3728                    'rest': 12000,
3729                },
3730                9000: {
3731                    'series': series,
3732                    'rest': 9000,
3733                },
3734                18000: {
3735                    'series': series,
3736                    'rest': 0,
3737                },
3738                27000: {
3739                    'series': series,
3740                    'rest': -9000,
3741                },
3742                36000: {
3743                    'series': series,
3744                    'rest': -18000,
3745                },
3746            }
3747
3748            selected_time = Time.time() - ZakatTracker.TimeCycle()
3749
3750            for total in case:
3751                if debug:
3752                    print('--------------------------------------------------------')
3753                    print(f'case[{total}]', case[total])
3754                for x in case[total]['series']:
3755                    self.track(
3756                        unscaled_value=x[0],
3757                        desc=f'test-{x} ages',
3758                        account=AccountName('ages'),
3759                        created_time_ns=selected_time * x[1],
3760                    )
3761
3762                unscaled_total = self.unscale(total)
3763                if debug:
3764                    print('unscaled_total', unscaled_total)
3765                refs = self.transfer(
3766                    unscaled_amount=unscaled_total,
3767                    from_account='ages',
3768                    to_account='future',
3769                    desc='Zakat Movement',
3770                    debug=debug,
3771                )
3772
3773                if debug:
3774                    print('refs', refs)
3775
3776                ages_cache_balance = self.balance('ages')
3777                ages_fresh_balance = self.balance('ages', False)
3778                rest = case[total]['rest']
3779                if debug:
3780                    print('source', ages_cache_balance, ages_fresh_balance, rest)
3781                assert ages_cache_balance == rest
3782                assert ages_fresh_balance == rest
3783
3784                future_cache_balance = self.balance('future')
3785                future_fresh_balance = self.balance('future', False)
3786                if debug:
3787                    print('target', future_cache_balance, future_fresh_balance, total)
3788                    print('refs', refs)
3789                assert future_cache_balance == total
3790                assert future_fresh_balance == total
3791
3792                # TODO: check boxes times for `ages` should equal box times in `future`
3793                for ref in self.__vault.account['ages'].box:
3794                    ages_capital = self.__vault.account['ages'].box[ref].capital
3795                    ages_rest = self.__vault.account['ages'].box[ref].rest
3796                    future_capital = 0
3797                    if ref in self.__vault.account['future'].box:
3798                        future_capital = self.__vault.account['future'].box[ref].capital
3799                    future_rest = 0
3800                    if ref in self.__vault.account['future'].box:
3801                        future_rest = self.__vault.account['future'].box[ref].rest
3802                    if ages_capital != 0 and future_capital != 0 and future_rest != 0:
3803                        if debug:
3804                            print('================================================================')
3805                            print('ages', ages_capital, ages_rest)
3806                            print('future', future_capital, future_rest)
3807                        if ages_rest == 0:
3808                            assert ages_capital == future_capital
3809                        elif ages_rest < 0:
3810                            assert -ages_capital == future_capital
3811                        elif ages_rest > 0:
3812                            assert ages_capital == ages_rest + future_capital
3813                self.reset()
3814                assert len(self.__vault.history) == 0
3815
3816            assert self.__history()
3817            assert self.__history(False) is False
3818            assert self.__history() is False
3819            assert self.__history(True)
3820            assert self.__history()
3821            if debug:
3822                print('####################################################################')
3823
3824            transaction = [
3825                (
3826                    20, 'wallet', 1, -2000, -2000, -2000, 1, 1,
3827                    2000, 2000, 2000, 1, 1,
3828                ),
3829                (
3830                    750, 'wallet', 'safe', -77000, -77000, -77000, 2, 2,
3831                    75000, 75000, 75000, 1, 1,
3832                ),
3833                (
3834                    600, 'safe', 'bank', 15000, 15000, 15000, 1, 2,
3835                    60000, 60000, 60000, 1, 1,
3836                ),
3837            ]
3838            for z in transaction:
3839                lock = self.lock()
3840                x = z[1]
3841                y = z[2]
3842                self.transfer(
3843                    unscaled_amount=z[0],
3844                    from_account=x,
3845                    to_account=y,
3846                    desc='test-transfer',
3847                    debug=debug,
3848                )
3849                zz = self.balance(x)
3850                if debug:
3851                    print(zz, z)
3852                assert zz == z[3]
3853                xx = self.accounts()[x]
3854                assert xx == z[3]
3855                assert self.balance(x, False) == z[4]
3856                assert xx == z[4]
3857
3858                s = 0
3859                log = self.__vault.account[x].log
3860                for i in log:
3861                    s += log[i].value
3862                if debug:
3863                    print('s', s, 'z[5]', z[5])
3864                assert s == z[5]
3865
3866                assert self.box_size(x) == z[6]
3867                assert self.log_size(x) == z[7]
3868
3869                yy = self.accounts()[y]
3870                assert self.balance(y) == z[8]
3871                assert yy == z[8]
3872                assert self.balance(y, False) == z[9]
3873                assert yy == z[9]
3874
3875                s = 0
3876                log = self.__vault.account[y].log
3877                for i in log:
3878                    s += log[i].value
3879                assert s == z[10]
3880
3881                assert self.box_size(y) == z[11]
3882                assert self.log_size(y) == z[12]
3883                assert lock is not None
3884                assert self.free(lock)
3885
3886            if debug:
3887                pp().pprint(self.check(2.17))
3888
3889            assert self.nolock()
3890            history_count = len(self.__vault.history)
3891            if debug:
3892                print('history-count', history_count)
3893            transaction_count = len(transaction)
3894            assert history_count == transaction_count
3895            assert not self.free(Time.time())
3896            assert self.free(self.lock())
3897            assert self.nolock()
3898            assert len(self.__vault.history) == transaction_count
3899
3900            # recall
3901
3902            assert self.nolock()
3903            assert len(self.__vault.history) == 3
3904            assert self.recall(False, debug=debug) is True
3905            assert len(self.__vault.history) == 2
3906            assert self.recall(False, debug=debug) is True
3907            assert len(self.__vault.history) == 1
3908            assert self.recall(False, debug=debug) is True
3909            assert len(self.__vault.history) == 0
3910            assert self.recall(False, debug=debug) is False
3911            assert len(self.__vault.history) == 0
3912
3913            # exchange
3914
3915            self.exchange('cash', 25, 3.75, '2024-06-25')
3916            self.exchange('cash', 22, 3.73, '2024-06-22')
3917            self.exchange('cash', 15, 3.69, '2024-06-15')
3918            self.exchange('cash', 10, 3.66)
3919
3920            assert self.nolock()
3921
3922            for i in range(1, 30):
3923                exchange = self.exchange('cash', i)
3924                rate, description, created = exchange.rate, exchange.description, exchange.time
3925                if debug:
3926                    print(i, rate, description, created)
3927                assert created
3928                if i < 10:
3929                    assert rate == 1
3930                    assert description is None
3931                elif i == 10:
3932                    assert rate == 3.66
3933                    assert description is None
3934                elif i < 15:
3935                    assert rate == 3.66
3936                    assert description is None
3937                elif i == 15:
3938                    assert rate == 3.69
3939                    assert description is not None
3940                elif i < 22:
3941                    assert rate == 3.69
3942                    assert description is not None
3943                elif i == 22:
3944                    assert rate == 3.73
3945                    assert description is not None
3946                elif i >= 25:
3947                    assert rate == 3.75
3948                    assert description is not None
3949                exchange = self.exchange('bank', i)
3950                rate, description, created = exchange.rate, exchange.description, exchange.time
3951                if debug:
3952                    print(i, rate, description, created)
3953                assert created
3954                assert rate == 1
3955                assert description is None
3956
3957            assert len(self.__vault.exchange) == 1
3958            assert len(self.exchanges()) == 1
3959            self.__vault.exchange.clear()
3960            assert len(self.__vault.exchange) == 0
3961            assert len(self.exchanges()) == 0
3962            self.reset()
3963
3964            # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية
3965            self.exchange('cash', ZakatTracker.day_to_time(25), 3.75, '2024-06-25')
3966            self.exchange('cash', ZakatTracker.day_to_time(22), 3.73, '2024-06-22')
3967            self.exchange('cash', ZakatTracker.day_to_time(15), 3.69, '2024-06-15')
3968            self.exchange('cash', ZakatTracker.day_to_time(10), 3.66)
3969
3970            assert self.nolock()
3971
3972            for i in [x * 0.12 for x in range(-15, 21)]:
3973                if i <= 0:
3974                    assert self.exchange('test', Time.time(), i, f'range({i})') == Exchange()
3975                else:
3976                    assert self.exchange('test', Time.time(), i, f'range({i})') is not Exchange()
3977
3978            assert self.nolock()
3979
3980           # اختبار النتائج باستخدام التواريخ بالنانو ثانية
3981            for i in range(1, 31):
3982                timestamp_ns = ZakatTracker.day_to_time(i)
3983                exchange = self.exchange('cash', timestamp_ns)
3984                rate, description, created = exchange.rate, exchange.description, exchange.time
3985                if debug:
3986                    print(i, rate, description, created)
3987                assert created
3988                if i < 10:
3989                    assert rate == 1
3990                    assert description is None
3991                elif i == 10:
3992                    assert rate == 3.66
3993                    assert description is None
3994                elif i < 15:
3995                    assert rate == 3.66
3996                    assert description is None
3997                elif i == 15:
3998                    assert rate == 3.69
3999                    assert description is not None
4000                elif i < 22:
4001                    assert rate == 3.69
4002                    assert description is not None
4003                elif i == 22:
4004                    assert rate == 3.73
4005                    assert description is not None
4006                elif i >= 25:
4007                    assert rate == 3.75
4008                    assert description is not None
4009                exchange = self.exchange('bank', i)
4010                rate, description, created = exchange.rate, exchange.description, exchange.time
4011                if debug:
4012                    print(i, rate, description, created)
4013                assert created
4014                assert rate == 1
4015                assert description is None
4016
4017            assert self.nolock()
4018
4019            self.reset()
4020
4021            # test transfer between accounts with different exchange rate
4022
4023            a_SAR = 'Bank (SAR)'
4024            b_USD = 'Bank (USD)'
4025            c_SAR = 'Safe (SAR)'
4026            # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer
4027            for case in [
4028                (0, a_SAR, 'SAR Gift', 1000, 100000),
4029                (1, a_SAR, 1),
4030                (0, b_USD, 'USD Gift', 500, 50000),
4031                (1, b_USD, 1),
4032                (2, b_USD, 3.75),
4033                (1, b_USD, 3.75),
4034                (3, 100, b_USD, a_SAR, '100 USD -> SAR', 40000, 137500),
4035                (0, c_SAR, 'Salary', 750, 75000),
4036                (3, 375, c_SAR, b_USD, '375 SAR -> USD', 37500, 50000),
4037                (3, 3.75, a_SAR, b_USD, '3.75 SAR -> USD', 137125, 50100),
4038            ]:
4039                if debug:
4040                    print('case', case)
4041                match (case[0]):
4042                    case 0:  # track
4043                        _, account, desc, x, balance = case
4044                        self.track(unscaled_value=x, desc=desc, account=account, debug=debug)
4045
4046                        cached_value = self.balance(account, cached=True)
4047                        fresh_value = self.balance(account, cached=False)
4048                        if debug:
4049                            print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value)
4050                        assert cached_value == balance
4051                        assert fresh_value == balance
4052                    case 1:  # check-exchange
4053                        _, account, expected_rate = case
4054                        t_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug)
4055                        if debug:
4056                            print('t-exchange', t_exchange)
4057                        assert t_exchange.rate == expected_rate
4058                    case 2:  # do-exchange
4059                        _, account, rate = case
4060                        self.exchange(account, rate=rate, debug=debug)
4061                        b_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug)
4062                        if debug:
4063                            print('b-exchange', b_exchange)
4064                        assert b_exchange.rate == rate
4065                    case 3:  # transfer
4066                        _, x, a, b, desc, a_balance, b_balance = case
4067                        self.transfer(x, a, b, desc, debug=debug)
4068
4069                        cached_value = self.balance(a, cached=True)
4070                        fresh_value = self.balance(a, cached=False)
4071                        if debug:
4072                            print(
4073                                'account', a,
4074                                'cached_value', cached_value,
4075                                'fresh_value', fresh_value,
4076                                'a_balance', a_balance,
4077                            )
4078                        assert cached_value == a_balance
4079                        assert fresh_value == a_balance
4080
4081                        cached_value = self.balance(b, cached=True)
4082                        fresh_value = self.balance(b, cached=False)
4083                        if debug:
4084                            print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value)
4085                        assert cached_value == b_balance
4086                        assert fresh_value == b_balance
4087
4088            # Transfer all in many chunks randomly from B to A
4089            a_SAR_balance = 137125
4090            b_USD_balance = 50100
4091            b_USD_exchange = self.exchange(b_USD)
4092            amounts = ZakatTracker.create_random_list(b_USD_balance, max_value=1000)
4093            if debug:
4094                print('amounts', amounts)
4095            i = 0
4096            for x in amounts:
4097                if debug:
4098                    print(f'{i} - transfer-with-exchange({x})')
4099                self.transfer(
4100                    unscaled_amount=self.unscale(x),
4101                    from_account=b_USD,
4102                    to_account=a_SAR,
4103                    desc=f'{x} USD -> SAR',
4104                    debug=debug,
4105                )
4106
4107                b_USD_balance -= x
4108                cached_value = self.balance(b_USD, cached=True)
4109                fresh_value = self.balance(b_USD, cached=False)
4110                if debug:
4111                    print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
4112                          b_USD_balance)
4113                assert cached_value == b_USD_balance
4114                assert fresh_value == b_USD_balance
4115
4116                a_SAR_balance += int(x * b_USD_exchange.rate)
4117                cached_value = self.balance(a_SAR, cached=True)
4118                fresh_value = self.balance(a_SAR, cached=False)
4119                if debug:
4120                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
4121                          a_SAR_balance, 'rate', b_USD_exchange.rate)
4122                assert cached_value == a_SAR_balance
4123                assert fresh_value == a_SAR_balance
4124                i += 1
4125
4126            # Transfer all in many chunks randomly from C to A
4127            c_SAR_balance = 37500
4128            amounts = ZakatTracker.create_random_list(c_SAR_balance, max_value=1000)
4129            if debug:
4130                print('amounts', amounts)
4131            i = 0
4132            for x in amounts:
4133                if debug:
4134                    print(f'{i} - transfer-with-exchange({x})')
4135                self.transfer(
4136                    unscaled_amount=self.unscale(x),
4137                    from_account=c_SAR,
4138                    to_account=a_SAR,
4139                    desc=f'{x} SAR -> a_SAR',
4140                    debug=debug,
4141                )
4142
4143                c_SAR_balance -= x
4144                cached_value = self.balance(c_SAR, cached=True)
4145                fresh_value = self.balance(c_SAR, cached=False)
4146                if debug:
4147                    print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
4148                          c_SAR_balance)
4149                assert cached_value == c_SAR_balance
4150                assert fresh_value == c_SAR_balance
4151
4152                a_SAR_balance += x
4153                cached_value = self.balance(a_SAR, cached=True)
4154                fresh_value = self.balance(a_SAR, cached=False)
4155                if debug:
4156                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
4157                          a_SAR_balance)
4158                assert cached_value == a_SAR_balance
4159                assert fresh_value == a_SAR_balance
4160                i += 1
4161
4162            assert self.save(f'accounts-transfer-with-exchange-rates.{self.ext()}')
4163
4164            # check & zakat with exchange rates for many cycles
4165
4166            lock = None
4167            for rate, values in {
4168                1: {
4169                    'in': [1000, 2000, 10000],
4170                    'exchanged': [100000, 200000, 1000000],
4171                    'out': [2500, 5000, 73140],
4172                },
4173                3.75: {
4174                    'in': [200, 1000, 5000],
4175                    'exchanged': [75000, 375000, 1875000],
4176                    'out': [1875, 9375, 137138],
4177                },
4178            }.items():
4179                a, b, c = values['in']
4180                m, n, o = values['exchanged']
4181                x, y, z = values['out']
4182                if debug:
4183                    print('rate', rate, 'values', values)
4184                for case in [
4185                    (a, 'safe', Time.time() - ZakatTracker.TimeCycle(), [
4186                        {'safe': {0: {'below_nisab': x}}},
4187                    ], False, m),
4188                    (b, 'safe', Time.time() - ZakatTracker.TimeCycle(), [
4189                        {'safe': {0: {'count': 1, 'total': y}}},
4190                    ], True, n),
4191                    (c, 'cave', Time.time() - (ZakatTracker.TimeCycle() * 3), [
4192                        {'cave': {0: {'count': 3, 'total': z}}},
4193                    ], True, o),
4194                ]:
4195                    if debug:
4196                        print(f'############# check(rate: {rate}) #############')
4197                        print('case', case)
4198                    self.reset()
4199                    self.exchange(account=case[1], created_time_ns=case[2], rate=rate)
4200                    self.track(
4201                        unscaled_value=case[0],
4202                        desc='test-check',
4203                        account=case[1],
4204                        created_time_ns=case[2],
4205                    )
4206                    assert self.snapshot()
4207
4208                    # assert self.nolock()
4209                    # history_size = len(self.__vault.history)
4210                    # print('history_size', history_size)
4211                    # assert history_size == 2
4212                    lock = self.lock()
4213                    assert lock
4214                    assert not self.nolock()
4215                    report = self.check(2.17, None, debug)
4216                    if debug:
4217                        print('report', report)
4218                    assert case[4] == report.valid
4219                    assert case[5] == report.statistics.overall_wealth
4220                    assert case[5] == report.statistics.zakatable_transactions_balance
4221
4222                    if debug:
4223                        pp().pprint(report.plan)
4224
4225                    for x in report.plan:
4226                        assert case[1] == x
4227                        if report.plan[x][0].below_nisab:
4228                            if debug:
4229                                print('[assert]', report.plan[x][0].total, case[3][0][x][0]['below_nisab'])
4230                            assert report.plan[x][0].total == case[3][0][x][0]['below_nisab']
4231                        else:
4232                            if debug:
4233                                print('[assert]', int(report.statistics.zakat_cut_balances), case[3][0][x][0]['total'])
4234                                print('[assert]', int(report.plan[x][0].total), case[3][0][x][0]['total'])
4235                                print('[assert]', report.plan[x][0].count ,case[3][0][x][0]['count'])
4236                            assert int(report.statistics.zakat_cut_balances) == case[3][0][x][0]['total']
4237                            assert int(report.plan[x][0].total) == case[3][0][x][0]['total']
4238                            assert report.plan[x][0].count == case[3][0][x][0]['count']
4239                    if debug:
4240                        pp().pprint(report)
4241                    result = self.zakat(report, debug=debug)
4242                    if debug:
4243                        print('zakat-result', result, case[4])
4244                    assert result == case[4]
4245                    report = self.check(2.17, None, debug)
4246                    assert report.valid is False
4247
4248            # storage
4249
4250            old_vault = dataclasses.replace(self.__vault)
4251            old_vault_deep = copy.deepcopy(self.__vault)
4252            old_vault_dict = dataclasses.asdict(self.__vault)
4253            _path = self.path(f'./zakat_test_db/test.{self.ext()}')
4254            if os.path.exists(_path):
4255                os.remove(_path)
4256            for hashed in [False, True]:
4257                self.save(hash_required=hashed)
4258                assert os.path.getsize(_path) > 0
4259                self.reset()
4260                assert self.recall(False, debug=debug) is False
4261                for hash_required in [False, True]:
4262                    if debug:
4263                        print(f'[storage] save({hashed}) and load({hash_required}) = {hashed and hash_required}')
4264                    self.load(hash_required=hashed and hash_required)
4265                    if debug:
4266                        print('[debug]', type(self.__vault))
4267                    assert self.__vault.account is not None
4268                    assert old_vault == self.__vault
4269                    assert old_vault_deep == self.__vault
4270                    assert old_vault_dict == dataclasses.asdict(self.__vault)
4271                    # corrupt the data
4272                    log_ref = NO_TIME()
4273                    tmp_file_ref = Time.time()
4274                    for k in self.__vault.account['cave'].log:
4275                        log_ref = k
4276                        self.__vault.account['cave'].log[k].file[tmp_file_ref] = 'HACKED'
4277                        break
4278                    assert old_vault != self.__vault
4279                    assert old_vault_deep != self.__vault
4280                    assert old_vault_dict != dataclasses.asdict(self.__vault)
4281                    # fix the data
4282                    del self.__vault.account['cave'].log[log_ref].file[tmp_file_ref]
4283                    assert old_vault == self.__vault
4284                    assert old_vault_deep == self.__vault
4285                    assert old_vault_dict == dataclasses.asdict(self.__vault)
4286                if hashed:
4287                    continue
4288                failed = False
4289                try:
4290                    hash_required = True
4291                    if debug:
4292                        print(f'x [storage] save({hashed}) and load({hash_required}) = {hashed and hash_required}')
4293                    self.load(hash_required=True)
4294                except:
4295                    failed = True
4296                assert failed
4297
4298            # recall after zakat
4299
4300            history_size = len(self.__vault.history)
4301            if debug:
4302                print('history_size', history_size)
4303            assert history_size == 3
4304            assert not self.nolock()
4305            assert self.recall(False, debug=debug) is False
4306            self.free(lock)
4307            assert self.nolock()
4308
4309            for i in range(3, 0, -1):
4310                history_size = len(self.__vault.history)
4311                if debug:
4312                    print('history_size', history_size)
4313                assert history_size == i
4314                assert self.recall(False, debug=debug) is True
4315
4316            assert self.nolock()
4317            assert self.recall(False, debug=debug) is False
4318
4319            history_size = len(self.__vault.history)
4320            if debug:
4321                print('history_size', history_size)
4322            assert history_size == 0
4323
4324            account_size = len(self.__vault.account)
4325            if debug:
4326                print('account_size', account_size)
4327            assert account_size == 0
4328
4329            report_size = len(self.__vault.report)
4330            if debug:
4331                print('report_size', report_size)
4332            assert report_size == 0
4333
4334            assert self.nolock()
4335
4336            # csv
4337
4338            csv_count = 1000
4339
4340            for with_rate, path in {
4341                False: 'test-import_csv-no-exchange',
4342                True: 'test-import_csv-with-exchange',
4343            }.items():
4344
4345                if debug:
4346                    print('test_import_csv', with_rate, path)
4347
4348                csv_path = path + '.csv'
4349                if os.path.exists(csv_path):
4350                    os.remove(csv_path)
4351                c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug)
4352                if debug:
4353                    print('generate_random_csv_file', c)
4354                assert c == csv_count
4355                assert os.path.getsize(csv_path) > 0
4356                cache_path = self.import_csv_cache_path()
4357                if os.path.exists(cache_path):
4358                    os.remove(cache_path)
4359                self.reset()
4360                lock = self.lock()
4361                (created, found, bad) = self.import_csv(csv_path, debug)
4362                bad_count = len(bad)
4363                if debug:
4364                    print(f'csv-imported: ({created}, {found}, {bad_count}) = count({csv_count})')
4365                    print('bad', bad)
4366                # TODO: assert created + found + bad_count == csv_count
4367                # TODO: assert created == csv_count
4368                # TODO: assert bad_count == 0
4369                assert bad_count > 0
4370                tmp_size = os.path.getsize(cache_path)
4371                assert tmp_size > 0
4372
4373                (created_2, found_2, bad_2) = self.import_csv(csv_path)
4374                bad_2_count = len(bad_2)
4375                if debug:
4376                    print(f'csv-imported: ({created_2}, {found_2}, {bad_2_count})')
4377                    print('bad', bad)
4378                assert bad_2_count > 0
4379                # TODO: assert tmp_size == os.path.getsize(cache_path)
4380                # TODO: assert created_2 + found_2 + bad_2_count == csv_count
4381                # TODO: assert created == found_2
4382                # TODO: assert bad_count == bad_2_count
4383                # TODO: assert found_2 == csv_count
4384                # TODO: assert bad_2_count == 0
4385                # TODO: assert created_2 == 0
4386
4387                # payment parts
4388
4389                positive_parts = self.build_payment_parts(100, positive_only=True)
4390                assert self.check_payment_parts(positive_parts) != 0
4391                assert self.check_payment_parts(positive_parts) != 0
4392                all_parts = self.build_payment_parts(300, positive_only=False)
4393                assert self.check_payment_parts(all_parts) != 0
4394                assert self.check_payment_parts(all_parts) != 0
4395                if debug:
4396                    pp().pprint(positive_parts)
4397                    pp().pprint(all_parts)
4398                # dynamic discount
4399                suite = []
4400                count = 3
4401                for exceed in [False, True]:
4402                    case = []
4403                    for part in [positive_parts, all_parts]:
4404                        #part = parts.copy()
4405                        demand = part.demand
4406                        if debug:
4407                            print(demand, part.total)
4408                        i = 0
4409                        z = demand / count
4410                        cp = PaymentParts(
4411                            demand=demand,
4412                            exceed=exceed,
4413                            total=part.total,
4414                        )
4415                        j = ''
4416                        for x, y in part.account.items():
4417                            x_exchange = self.exchange(x)
4418                            zz = self.exchange_calc(z, 1, x_exchange.rate)
4419                            if exceed and zz <= demand:
4420                                i += 1
4421                                y.part = zz
4422                                if debug:
4423                                    print(exceed, y)
4424                                cp.account[x] = y
4425                                case.append(y)
4426                            elif not exceed and y.balance >= zz:
4427                                i += 1
4428                                y.part = zz
4429                                if debug:
4430                                    print(exceed, y)
4431                                cp.account[x] = y
4432                                case.append(y)
4433                            j = x
4434                            if i >= count:
4435                                break
4436                        if debug:
4437                            print('[debug]', cp.account[j])
4438                        if cp.account[j] != AccountPaymentPart(0.0, 0.0, 0.0):
4439                            suite.append(cp)
4440                if debug:
4441                    print('suite', len(suite))
4442                for case in suite:
4443                    if debug:
4444                        print('case', case)
4445                    result = self.check_payment_parts(case)
4446                    if debug:
4447                        print('check_payment_parts', result, f'exceed: {exceed}')
4448                    assert result == 0
4449
4450                    report = self.check(2.17, None, debug)
4451                    if debug:
4452                        print('valid', report.valid)
4453                    zakat_result = self.zakat(report, parts=case, debug=debug)
4454                    if debug:
4455                        print('zakat-result', zakat_result)
4456                    assert report.valid == zakat_result
4457
4458                assert self.free(lock)
4459
4460            assert self.save(path + f'.{self.ext()}')
4461
4462            assert self.save(f'1000-transactions-test.{self.ext()}')
4463            return True
4464        except Exception as e:
4465            # pp().pprint(self.__vault)
4466            assert self.save(f'test-snapshot.{self.ext()}')
4467            raise e
class AccountName(builtins.str):
200class AccountName(str):
201    """Represents the name of an account."""
202    pass

Represents the name of an account.

class Timestamp(builtins.int):
190class Timestamp(int):
191    """Represents a timestamp as an integer."""
192    pass

Represents a timestamp as an integer.

@dataclasses.dataclass
class Box:
205@dataclasses.dataclass
206class Box:
207    """
208    Represents a box containing financial information.
209
210    Attributes:
211    - capital: The initial capital of the box.
212    - count: The number of zakat applied on the box.
213    - last: The timestamp of the last zakat on the box.
214    - rest: The remaining value in the box.
215    - total: The total value of zakat applied on the box.
216    """
217    capital: int #= dataclasses.field(metadata={"frozen": True})
218    count: int
219    last: int
220    rest: int
221    total: int

Represents a box containing financial information.

Attributes:

  • capital: The initial capital of the box.
  • count: The number of zakat applied on the box.
  • last: The timestamp of the last zakat on the box.
  • rest: The remaining value in the box.
  • total: The total value of zakat applied on the box.
Box(capital: int, count: int, last: int, rest: int, total: int)
capital: int
count: int
last: int
rest: int
total: int
@dataclasses.dataclass
class Log:
224@dataclasses.dataclass
225class Log:
226    """
227    Represents a log entry for an account.
228
229    Attributes:
230    - value: The value of the log entry.
231    - desc: A description of the log entry.
232    - ref: An optional timestamp reference.
233    - file: A dictionary mapping timestamps to file paths.
234    """
235    value: int
236    desc: str
237    ref: Optional[Timestamp]
238    file: dict[Timestamp, str] = dataclasses.field(default_factory=dict)

Represents a log entry for an account.

Attributes:

  • value: The value of the log entry.
  • desc: A description of the log entry.
  • ref: An optional timestamp reference.
  • file: A dictionary mapping timestamps to file paths.
Log( value: int, desc: str, ref: Optional[Timestamp], file: dict[Timestamp, str] = <factory>)
value: int
desc: str
ref: Optional[Timestamp]
file: dict[Timestamp, str]
@dataclasses.dataclass
class Account:
241@dataclasses.dataclass
242class Account:
243    """
244    Represents a financial account.
245
246    Attributes:
247    - balance: The current balance of the account.
248    - created: The timestamp when the account was created.
249    - box: A dictionary mapping timestamps to Box objects.
250    - count: A counter for logs, initialized to 0.
251    - log: A dictionary mapping timestamps to Log objects.
252    - hide: A boolean indicating whether the account is hidden.
253    - zakatable: A boolean indicating whether the account is subject to zakat.
254    """
255    balance: int
256    created: Timestamp
257    box: dict[Timestamp, Box] = dataclasses.field(default_factory=dict)
258    count: int = dataclasses.field(default_factory=factory_value(0))
259    log: dict[Timestamp, Log] = dataclasses.field(default_factory=dict)
260    hide: bool = dataclasses.field(default_factory=factory_value(False))
261    zakatable: bool = dataclasses.field(default_factory=factory_value(True))

Represents a financial account.

Attributes:

  • balance: The current balance of the account.
  • created: The timestamp when the account was created.
  • box: A dictionary mapping timestamps to Box objects.
  • count: A counter for logs, initialized to 0.
  • log: A dictionary mapping timestamps to Log objects.
  • hide: A boolean indicating whether the account is hidden.
  • zakatable: A boolean indicating whether the account is subject to zakat.
Account( balance: int, created: Timestamp, box: dict[Timestamp, Box] = <factory>, count: int = <factory>, log: dict[Timestamp, Log] = <factory>, hide: bool = <factory>, zakatable: bool = <factory>)
balance: int
created: Timestamp
box: dict[Timestamp, Box]
count: int
log: dict[Timestamp, Log]
hide: bool
zakatable: bool
@dataclasses.dataclass
class Exchange:
264@dataclasses.dataclass
265class Exchange:
266    """
267    Represents an exchange rate and related information.
268
269    Attributes:
270    - rate: The exchange rate (optional).
271    - description: A description of the exchange (optional).
272    - time: The timestamp of the exchange (optional).
273    """
274    rate: Optional[float] = None
275    description: Optional[str] = None
276    time: Optional[Timestamp] = None

Represents an exchange rate and related information.

Attributes:

  • rate: The exchange rate (optional).
  • description: A description of the exchange (optional).
  • time: The timestamp of the exchange (optional).
Exchange( rate: Optional[float] = None, description: Optional[str] = None, time: Optional[Timestamp] = None)
rate: Optional[float] = None
description: Optional[str] = None
time: Optional[Timestamp] = None
@dataclasses.dataclass
class History:
279@dataclasses.dataclass
280class History:
281    """
282    Represents a history entry for an account action.
283
284    Attributes:
285    - action: The action performed.
286    - account: The name of the account (optional).
287    - ref: An optional timestamp reference.
288    - file: An optional timestamp for a file.
289    - key: An optional key.
290    - value: An optional value.
291    - math: An optional math operation.
292    """
293    action: Action
294    account: Optional[AccountName]
295    ref: Optional[Timestamp]
296    file: Optional[Timestamp]
297    key: Optional[str]
298    value: Optional[any] # !!!
299    math: Optional[MathOperation]

Represents a history entry for an account action.

Attributes:

  • action: The action performed.
  • account: The name of the account (optional).
  • ref: An optional timestamp reference.
  • file: An optional timestamp for a file.
  • key: An optional key.
  • value: An optional value.
  • math: An optional math operation.
History( action: Action, account: Optional[AccountName], ref: Optional[Timestamp], file: Optional[Timestamp], key: Optional[str], value: Optional[<built-in function any>], math: Optional[MathOperation])
action: Action
account: Optional[AccountName]
ref: Optional[Timestamp]
file: Optional[Timestamp]
key: Optional[str]
value: Optional[<built-in function any>]
math: Optional[MathOperation]
@dataclasses.dataclass
class Vault:
362@dataclasses.dataclass
363class Vault:
364    """
365    Represents a vault containing accounts, exchanges, and history.
366
367    Attributes:
368    - account: A dictionary mapping account names to Account objects.
369    - exchange: A dictionary mapping account names to dictionaries of timestamps and Exchange objects.
370    - history: A dictionary mapping timestamps to lists of History objects.
371    - lock: An optional timestamp for a lock.
372    - report: A dictionary mapping timestamps to tuples.
373    """
374    account: dict[AccountName, Account] = dataclasses.field(default_factory=dict)
375    exchange: dict[AccountName, dict[Timestamp, Exchange]] = dataclasses.field(default_factory=dict)
376    history: dict[Timestamp, list[History]] = dataclasses.field(default_factory=dict)
377    lock: Optional[Timestamp] = None
378    report: dict[Timestamp, ZakatReport] = dataclasses.field(default_factory=dict)

Represents a vault containing accounts, exchanges, and history.

Attributes:

  • account: A dictionary mapping account names to Account objects.
  • exchange: A dictionary mapping account names to dictionaries of timestamps and Exchange objects.
  • history: A dictionary mapping timestamps to lists of History objects.
  • lock: An optional timestamp for a lock.
  • report: A dictionary mapping timestamps to tuples.
Vault( account: dict[AccountName, Account] = <factory>, exchange: dict[AccountName, dict[Timestamp, Exchange]] = <factory>, history: dict[Timestamp, list[History]] = <factory>, lock: Optional[Timestamp] = None, report: dict[Timestamp, ZakatReport] = <factory>)
account: dict[AccountName, Account]
exchange: dict[AccountName, dict[Timestamp, Exchange]]
history: dict[Timestamp, list[History]]
lock: Optional[Timestamp] = None
report: dict[Timestamp, ZakatReport]
@dataclasses.dataclass
class AccountPaymentPart:
381@dataclasses.dataclass
382class AccountPaymentPart:
383    """
384    Represents a payment part for an account.
385
386    Attributes:
387    - balance: The balance of the payment part.
388    - rate: The rate of the payment part.
389    - part: The part of the payment.
390    """
391    balance: float
392    rate: float
393    part: float

Represents a payment part for an account.

Attributes:

  • balance: The balance of the payment part.
  • rate: The rate of the payment part.
  • part: The part of the payment.
AccountPaymentPart(balance: float, rate: float, part: float)
balance: float
rate: float
part: float
@dataclasses.dataclass
class PaymentParts:
396@dataclasses.dataclass
397class PaymentParts:
398    """
399    Represents payment parts for multiple accounts.
400
401    Attributes:
402    - exceed: A boolean indicating whether the payment exceeds a limit.
403    - demand: The demand for payment.
404    - total: The total payment.
405    - account: A dictionary mapping account names to AccountPaymentPart objects.
406    """
407    exceed: bool
408    demand: int
409    total: float
410    account: dict[AccountName, AccountPaymentPart] = dataclasses.field(default_factory=dict)

Represents payment parts for multiple accounts.

Attributes:

  • exceed: A boolean indicating whether the payment exceeds a limit.
  • demand: The demand for payment.
  • total: The total payment.
  • account: A dictionary mapping account names to AccountPaymentPart objects.
PaymentParts( exceed: bool, demand: int, total: float, account: dict[AccountName, AccountPaymentPart] = <factory>)
exceed: bool
demand: int
total: float
@dataclasses.dataclass
class SubtractAge:
413@dataclasses.dataclass
414class SubtractAge:
415    """
416    Represents an age subtraction.
417
418    Attributes:
419    - box_ref: The timestamp reference for the box.
420    - total: The total amount to subtract.
421    """
422    box_ref: Timestamp
423    total: int

Represents an age subtraction.

Attributes:

  • box_ref: The timestamp reference for the box.
  • total: The total amount to subtract.
SubtractAge(box_ref: Timestamp, total: int)
box_ref: Timestamp
total: int
class SubtractAges(list[zakat.zakat_tracker.SubtractAge]):
426class SubtractAges(list[SubtractAge]):
427    """A list of SubtractAge objects."""
428    pass

A list of SubtractAge objects.

@dataclasses.dataclass
class SubtractReport:
431@dataclasses.dataclass
432class SubtractReport:
433    """
434    Represents a report of age subtractions.
435
436    Attributes:
437    - log_ref: The timestamp reference for the log.
438    - ages: A list of SubtractAge objects.
439    """
440    log_ref: Timestamp
441    ages: SubtractAges

Represents a report of age subtractions.

Attributes:

  • log_ref: The timestamp reference for the log.
  • ages: A list of SubtractAge objects.
SubtractReport( log_ref: Timestamp, ages: SubtractAges)
log_ref: Timestamp
ages: SubtractAges
@dataclasses.dataclass
class TransferTime:
444@dataclasses.dataclass
445class TransferTime:
446    """
447    Represents a transfer time.
448
449    Attributes:
450    - box_ref: The timestamp reference for the box.
451    - log_ref: The timestamp reference for the log.
452    """
453    box_ref: Timestamp
454    log_ref: Timestamp

Represents a transfer time.

Attributes:

  • box_ref: The timestamp reference for the box.
  • log_ref: The timestamp reference for the log.
TransferTime( box_ref: Timestamp, log_ref: Timestamp)
box_ref: Timestamp
log_ref: Timestamp
class TransferTimes(list[zakat.zakat_tracker.TransferTime]):
457class TransferTimes(list[TransferTime]):
458    """A list of TransferTime objects."""
459    pass

A list of TransferTime objects.

@dataclasses.dataclass
class TransferRecord:
462@dataclasses.dataclass
463class TransferRecord:
464    """
465    Represents a transfer record.
466
467    Attributes:
468    - box_ref: The timestamp reference for the box.
469    - times: A list of TransferTime objects.
470    """
471    box_ref: Timestamp
472    times: TransferTimes

Represents a transfer record.

Attributes:

  • box_ref: The timestamp reference for the box.
  • times: A list of TransferTime objects.
TransferRecord( box_ref: Timestamp, times: TransferTimes)
box_ref: Timestamp
times: TransferTimes
class TransferReport(list[zakat.zakat_tracker.TransferRecord]):
475class TransferReport(list[TransferRecord]):
476    """A list of TransferRecord objects."""
477    pass

A list of TransferRecord objects.

@dataclasses.dataclass
class BoxPlan:
302@dataclasses.dataclass
303class BoxPlan:
304    """
305    Represents a plan for a box.
306
307    Attributes:
308    - box: The Box object.
309    - log: The Log object.
310    - exchange: The Exchange object.
311    - below_nisab: A boolean indicating whether the value is below nisab.
312    - total: The total value.
313    - count: The count.
314    - ref: The timestamp reference.
315    """
316    box: Box
317    log: Log
318    exchange: Exchange
319    below_nisab: bool
320    total: float
321    count: int
322    ref: Timestamp

Represents a plan for a box.

Attributes:

  • box: The Box object.
  • log: The Log object.
  • exchange: The Exchange object.
  • below_nisab: A boolean indicating whether the value is below nisab.
  • total: The total value.
  • count: The count.
  • ref: The timestamp reference.
BoxPlan( box: Box, log: Log, exchange: Exchange, below_nisab: bool, total: float, count: int, ref: Timestamp)
box: Box
log: Log
exchange: Exchange
below_nisab: bool
total: float
count: int
ref: Timestamp
class ZakatPlan(dict[zakat.zakat_tracker.AccountName, list[zakat.zakat_tracker.BoxPlan]]):
325class ZakatPlan(dict[AccountName, list[BoxPlan]]):
326    """A dictionary mapping account names to lists of BoxPlan objects."""
327    pass

A dictionary mapping account names to lists of BoxPlan objects.

@dataclasses.dataclass
class ZakatReportStatistics:
330@dataclasses.dataclass
331class ZakatReportStatistics:
332    """
333    Represents statistics for a zakat report.
334
335    Attributes:
336    - overall_wealth: The overall wealth.
337    - zakatable_transactions_count: The count of zakatable transactions.
338    - zakatable_transactions_balance: The balance of zakatable transactions.
339    - zakat_cut_balances: The zakat cut balances.
340    """
341    overall_wealth: int = 0
342    zakatable_transactions_count: int = 0
343    zakatable_transactions_balance: int = 0
344    zakat_cut_balances: int = 0

Represents statistics for a zakat report.

Attributes:

  • overall_wealth: The overall wealth.
  • zakatable_transactions_count: The count of zakatable transactions.
  • zakatable_transactions_balance: The balance of zakatable transactions.
  • zakat_cut_balances: The zakat cut balances.
ZakatReportStatistics( overall_wealth: int = 0, zakatable_transactions_count: int = 0, zakatable_transactions_balance: int = 0, zakat_cut_balances: int = 0)
overall_wealth: int = 0
zakatable_transactions_count: int = 0
zakatable_transactions_balance: int = 0
zakat_cut_balances: int = 0
@dataclasses.dataclass
class ZakatReport:
347@dataclasses.dataclass
348class ZakatReport:
349    """
350    Represents a zakat report.
351
352    Attributes:
353    - valid: A boolean indicating whether the Zakat is available.
354    - statistics: The ZakatReportStatistics object.
355    - plan: The ZakatPlan object.
356    """
357    valid: bool
358    statistics: ZakatReportStatistics
359    plan: ZakatPlan

Represents a zakat report.

Attributes:

  • valid: A boolean indicating whether the Zakat is available.
  • statistics: The ZakatReportStatistics object.
  • plan: The ZakatPlan object.
ZakatReport( valid: bool, statistics: ZakatReportStatistics, plan: ZakatPlan)
valid: bool
statistics: ZakatReportStatistics
plan: ZakatPlan
def test(path: str = None, debug: bool = False):
4470def test(path: str = None, debug: bool = False):
4471    """
4472    Executes a test suite for the ZakatTracker.
4473
4474    This function initializes a ZakatTracker instance, optionally using a specified
4475    database path or a temporary directory. It then runs the test suite and, if debug
4476    mode is enabled, prints detailed test results and execution time.
4477
4478    Parameters:
4479    - path (str, optional): The path to the ZakatTracker database. If None, a
4480                            temporary directory is created. Defaults to None.
4481    - debug (bool, optional): Enables debug mode, which prints detailed test
4482                            results and execution time. Defaults to False.
4483
4484    Returns:
4485    None. The function asserts the result of the ZakatTracker's test suite.
4486
4487    Raises:
4488    - AssertionError: If the ZakatTracker's test suite fails.
4489
4490    Examples:
4491    - `test()` Runs tests using a temporary database.
4492    - `test(debug=True)` Runs the test suite in debug mode with a temporary directory.
4493    - `test(path="/path/to/my/db")` Runs tests using a specified database path.
4494    - `test(path="/path/to/my/db", debug=False)` Runs test suite with specified path.
4495    """
4496    no_path = path is None
4497    if no_path:
4498        path = tempfile.mkdtemp()
4499        print(f"Random database path {path}")
4500    if os.path.exists(path):
4501        shutil.rmtree(path)
4502    assert ZakatTracker(':memory:').memory_mode()
4503    ledger = ZakatTracker(
4504        db_path=path,
4505        history_mode=True,
4506    )
4507    start = time.time_ns()
4508    assert not ledger.memory_mode()
4509    assert ledger.test(debug=debug)
4510    if no_path and os.path.exists(path):
4511        shutil.rmtree(path)
4512    if debug:
4513        print('#########################')
4514        print('######## TEST DONE ########')
4515        print('#########################')
4516        print(Time.duration_from_nanoseconds(time.time_ns() - start))
4517        print('#########################')

Executes a test suite for the ZakatTracker.

This function initializes a ZakatTracker instance, optionally using a specified database path or a temporary directory. It then runs the test suite and, if debug mode is enabled, prints detailed test results and execution time.

Parameters:

  • path (str, optional): The path to the ZakatTracker database. If None, a temporary directory is created. Defaults to None.
  • debug (bool, optional): Enables debug mode, which prints detailed test results and execution time. Defaults to False.

Returns: None. The function asserts the result of the ZakatTracker's test suite.

Raises:

  • AssertionError: If the ZakatTracker's test suite fails.

Examples:

  • test() Runs tests using a temporary database.
  • test(debug=True) Runs the test suite in debug mode with a temporary directory.
  • test(path="/path/to/my/db") Runs tests using a specified database path.
  • test(path="/path/to/my/db", debug=False) Runs test suite with specified path.
@enum.unique
class Action(enum.Enum):
104@enum.unique
105class Action(enum.Enum):
106    """
107    Enumeration representing various actions that can be performed.
108
109    Members:
110    - CREATE: Represents the creation action ('CREATE').
111    - TRACK: Represents the tracking action ('TRACK').
112    - LOG: Represents the logging action ('LOG').
113    - SUBTRACT: Represents the subtract action ('SUBTRACT').
114    - ADD_FILE: Represents the action of adding a file ('ADD_FILE').
115    - REMOVE_FILE: Represents the action of removing a file ('REMOVE_FILE').
116    - BOX_TRANSFER: Represents the action of transferring a box ('BOX_TRANSFER').
117    - EXCHANGE: Represents the exchange action ('EXCHANGE').
118    - REPORT: Represents the reporting action ('REPORT').
119    - ZAKAT: Represents a Zakat related action ('ZAKAT').
120    """
121    CREATE = 'CREATE'
122    TRACK = 'TRACK'
123    LOG = 'LOG'
124    SUBTRACT = 'SUBTRACT'
125    ADD_FILE = 'ADD_FILE'
126    REMOVE_FILE = 'REMOVE_FILE'
127    BOX_TRANSFER = 'BOX_TRANSFER'
128    EXCHANGE = 'EXCHANGE'
129    REPORT = 'REPORT'
130    ZAKAT = 'ZAKAT'

Enumeration representing various actions that can be performed.

Members:

  • CREATE: Represents the creation action ('CREATE').
  • TRACK: Represents the tracking action ('TRACK').
  • LOG: Represents the logging action ('LOG').
  • SUBTRACT: Represents the subtract action ('SUBTRACT').
  • ADD_FILE: Represents the action of adding a file ('ADD_FILE').
  • REMOVE_FILE: Represents the action of removing a file ('REMOVE_FILE').
  • BOX_TRANSFER: Represents the action of transferring a box ('BOX_TRANSFER').
  • EXCHANGE: Represents the exchange action ('EXCHANGE').
  • REPORT: Represents the reporting action ('REPORT').
  • ZAKAT: Represents a Zakat related action ('ZAKAT').
CREATE = <Action.CREATE: 'CREATE'>
TRACK = <Action.TRACK: 'TRACK'>
LOG = <Action.LOG: 'LOG'>
SUBTRACT = <Action.SUBTRACT: 'SUBTRACT'>
ADD_FILE = <Action.ADD_FILE: 'ADD_FILE'>
REMOVE_FILE = <Action.REMOVE_FILE: 'REMOVE_FILE'>
BOX_TRANSFER = <Action.BOX_TRANSFER: 'BOX_TRANSFER'>
EXCHANGE = <Action.EXCHANGE: 'EXCHANGE'>
REPORT = <Action.REPORT: 'REPORT'>
ZAKAT = <Action.ZAKAT: 'ZAKAT'>
class JSONEncoder(json.encoder.JSONEncoder):
480class JSONEncoder(json.JSONEncoder):
481    """
482    Custom JSON encoder to handle specific object types.
483
484    This encoder overrides the default `default` method to serialize:
485    - `Action` and `MathOperation` enums as their member names.
486    - `decimal.Decimal` instances as floats.
487
488    Example:
489    ```bash
490    >>> json.dumps(Action.CREATE, cls=JSONEncoder)
491    ''CREATE''
492    >>> json.dumps(decimal.Decimal('10.5'), cls=JSONEncoder)
493    '10.5'
494    ```
495    """
496    def default(self, o):
497        """
498        Overrides the default `default` method to serialize specific object types.
499
500        Parameters:
501        - o: The object to serialize.
502
503        Returns:
504        - The serialized object.
505        """
506        if isinstance(o, (Action, MathOperation)):
507            return o.name  # Serialize as the enum member's name
508        if isinstance(o, decimal.Decimal):
509            return float(o)
510        if isinstance(o, Exception):
511            return str(o)
512        if isinstance(o, Vault):
513            return dataclasses.asdict(o)
514        return super().default(o)

Custom JSON encoder to handle specific object types.

This encoder overrides the default default method to serialize:

Example:

>>> json.dumps(Action.CREATE, cls=JSONEncoder)
''CREATE''
>>> json.dumps(decimal.Decimal('10.5'), cls=JSONEncoder)
'10.5'
def default(self, o):
496    def default(self, o):
497        """
498        Overrides the default `default` method to serialize specific object types.
499
500        Parameters:
501        - o: The object to serialize.
502
503        Returns:
504        - The serialized object.
505        """
506        if isinstance(o, (Action, MathOperation)):
507            return o.name  # Serialize as the enum member's name
508        if isinstance(o, decimal.Decimal):
509            return float(o)
510        if isinstance(o, Exception):
511            return str(o)
512        if isinstance(o, Vault):
513            return dataclasses.asdict(o)
514        return super().default(o)

Overrides the default default method to serialize specific object types.

Parameters:

  • o: The object to serialize.

Returns:

  • The serialized object.
class JSONDecoder(json.decoder.JSONDecoder):
516class JSONDecoder(json.JSONDecoder):
517    """
518    Custom JSON decoder to handle specific object types.
519
520    This decoder overrides the `object_hook` method to deserialize:
521    - Strings representing enum member names back to their respective enum values.
522    - Floats back to `decimal.Decimal` instances.
523
524    Example:
525    ```bash
526    >>> json.loads('{"action": "CREATE"}', cls=JSONDecoder)
527    {'action': <Action.CREATE: 1>}
528    >>> json.loads('{"value": 10.5}', cls=JSONDecoder)
529    {'value': Decimal('10.5')}
530    ```
531    """
532    def object_hook(self, obj):
533        """
534        Overrides the default `object_hook` method to deserialize specific object types.
535
536        Parameters:
537        - obj: The object to deserialize.
538
539        Returns:
540        - The deserialized object.
541        """
542        if isinstance(obj, str) and obj in Action.__members__:
543            return Action[obj]
544        if isinstance(obj, str) and obj in MathOperation.__members__:
545            return MathOperation[obj]
546        if isinstance(obj, float):
547            return decimal.Decimal(str(obj))
548        return obj

Custom JSON decoder to handle specific object types.

This decoder overrides the object_hook method to deserialize:

  • Strings representing enum member names back to their respective enum values.
  • Floats back to decimal.Decimal instances.

Example:

>>> json.loads('{"action": "CREATE"}', cls=JSONDecoder)
{'action': <Action.CREATE: 1>}
>>> json.loads('{"value": 10.5}', cls=JSONDecoder)
{'value': Decimal('10.5')}
def object_hook(self, obj):
532    def object_hook(self, obj):
533        """
534        Overrides the default `object_hook` method to deserialize specific object types.
535
536        Parameters:
537        - obj: The object to deserialize.
538
539        Returns:
540        - The deserialized object.
541        """
542        if isinstance(obj, str) and obj in Action.__members__:
543            return Action[obj]
544        if isinstance(obj, str) and obj in MathOperation.__members__:
545            return MathOperation[obj]
546        if isinstance(obj, float):
547            return decimal.Decimal(str(obj))
548        return obj

Overrides the default object_hook method to deserialize specific object types.

Parameters:

  • obj: The object to deserialize.

Returns:

  • The deserialized object.
@enum.unique
class MathOperation(enum.Enum):
133@enum.unique
134class MathOperation(enum.Enum):
135    """
136    Enumeration representing mathematical operations.
137
138    Members:
139    - ADDITION: Represents the addition operation ('ADDITION').
140    - EQUAL: Represents the equality operation ('EQUAL').
141    - SUBTRACTION: Represents the subtraction operation ('SUBTRACTION').
142    """
143    ADDITION = 'ADDITION'
144    EQUAL = 'EQUAL'
145    SUBTRACTION = 'SUBTRACTION'

Enumeration representing mathematical operations.

Members:

  • ADDITION: Represents the addition operation ('ADDITION').
  • EQUAL: Represents the equality operation ('EQUAL').
  • SUBTRACTION: Represents the subtraction operation ('SUBTRACTION').
ADDITION = <MathOperation.ADDITION: 'ADDITION'>
EQUAL = <MathOperation.EQUAL: 'EQUAL'>
SUBTRACTION = <MathOperation.SUBTRACTION: 'SUBTRACTION'>
@enum.unique
class WeekDay(enum.Enum):
 81@enum.unique
 82class WeekDay(enum.Enum):
 83    """
 84    Enumeration representing the days of the week.
 85
 86    Members:
 87    - MONDAY: Represents Monday (0).
 88    - TUESDAY: Represents Tuesday (1).
 89    - WEDNESDAY: Represents Wednesday (2).
 90    - THURSDAY: Represents Thursday (3).
 91    - FRIDAY: Represents Friday (4).
 92    - SATURDAY: Represents Saturday (5).
 93    - SUNDAY: Represents Sunday (6).
 94    """
 95    MONDAY = 0
 96    TUESDAY = 1
 97    WEDNESDAY = 2
 98    THURSDAY = 3
 99    FRIDAY = 4
100    SATURDAY = 5
101    SUNDAY = 6

Enumeration representing the days of the week.

Members:

  • MONDAY: Represents Monday (0).
  • TUESDAY: Represents Tuesday (1).
  • WEDNESDAY: Represents Wednesday (2).
  • THURSDAY: Represents Thursday (3).
  • FRIDAY: Represents Friday (4).
  • SATURDAY: Represents Saturday (5).
  • SUNDAY: Represents Sunday (6).
MONDAY = <WeekDay.MONDAY: 0>
TUESDAY = <WeekDay.TUESDAY: 1>
WEDNESDAY = <WeekDay.WEDNESDAY: 2>
THURSDAY = <WeekDay.THURSDAY: 3>
FRIDAY = <WeekDay.FRIDAY: 4>
SATURDAY = <WeekDay.SATURDAY: 5>
SUNDAY = <WeekDay.SUNDAY: 6>
def start_file_server( database_path: str, database_callback: Optional[<built-in function callable>] = None, csv_callback: Optional[<built-in function callable>] = None, debug: bool = False) -> tuple:
110def start_file_server(database_path: str, database_callback: Optional[callable] = None, csv_callback: Optional[callable] = None,
111                      debug: bool = False) -> tuple:
112    """
113    Starts a multi-purpose WSGI server to manage file interactions for a Zakat application.
114
115    This server facilitates the following functionalities:
116
117    1. GET `/{file_uuid}/get`: Download the database file specified by `database_path`.
118    2. GET `/{file_uuid}/upload`: Display an HTML form for uploading files.
119    3. POST `/{file_uuid}/upload`: Handle file uploads, distinguishing between:
120        - Database File (.db): Replaces the existing database with the uploaded one.
121        - CSV File (.csv): Imports data from the CSV into the existing database.
122
123    Parameters:
124    - database_path (str): The path to the camel database file.
125    - database_callback (callable, optional): A function to call after a successful database upload.
126                                                It receives the uploaded database path as its argument.
127    - csv_callback (callable, optional): A function to call after a successful CSV upload. It receives the uploaded CSV path,
128                                           the database path, and the debug flag as its arguments.
129    - debug (bool, optional): If True, print debugging information. Defaults to False.
130
131    Returns:
132    - Tuple[str, str, str, threading.Thread, Callable[[], None]]: A tuple containing:
133        - file_name (str): The name of the database file.
134        - download_url (str): The URL to download the database file.
135        - upload_url (str): The URL to access the file upload form.
136        - server_thread (threading.Thread): The thread running the server.
137        - shutdown_server (Callable[[], None]): A function to gracefully shut down the server.
138
139    Example:
140    ```python
141    _, download_url, upload_url, server_thread, shutdown_server = start_file_server("zakat.db")
142    print(f"Download database: {download_url}")
143    print(f"Upload files: {upload_url}")
144    server_thread.start()
145    # ... later ...
146    shutdown_server()
147    ```
148    """
149    file_uuid = uuid.uuid4()
150    file_name = os.path.basename(database_path)
151
152    port = find_available_port()
153    download_url = f"http://localhost:{port}/{file_uuid}/get"
154    upload_url = f"http://localhost:{port}/{file_uuid}/upload"
155
156    # Upload directory
157    upload_directory = "./uploads"
158    os.makedirs(upload_directory, exist_ok=True)
159
160    # HTML templates
161    upload_form = f"""
162    <html lang="en">
163        <head>
164            <title>Zakat File Server</title>
165        </head>
166    <body>
167    <h1>Zakat File Server</h1>
168    <h3>You can download the <a target="__blank" href="{download_url}">database file</a>...</h3>
169    <h3>Or upload a new file to restore a database or import `CSV` file:</h3>
170    <form action="/{file_uuid}/upload" method="post" enctype="multipart/form-data">
171        <input type="file" name="file" required><br/>
172        <input type="radio" id="{FileType.Database.value}" name="upload_type" value="{FileType.Database.value}" required>
173        <label for="database">Database File</label><br/>
174        <input type="radio"id="{FileType.CSV.value}" name="upload_type" value="{FileType.CSV.value}">
175        <label for="csv">CSV File</label><br/>
176        <input type="submit" value="Upload"><br/>
177    </form>
178    </body></html>
179    """
180
181    # WSGI application
182    def wsgi_app(environ, start_response):
183        path = environ.get('PATH_INFO', '')
184        method = environ.get('REQUEST_METHOD', 'GET')
185
186        if path == f"/{file_uuid}/get" and method == 'GET':
187            # GET: Serve the existing file
188            try:
189                with open(database_path, "rb") as f:
190                    file_content = f.read()
191                    
192                start_response('200 OK', [
193                    ('Content-type', 'application/octet-stream'),
194                    ('Content-Disposition', f'attachment; filename="{file_name}"'),
195                    ('Content-Length', str(len(file_content)))
196                ])
197                return [file_content]
198            except FileNotFoundError:
199                start_response('404 Not Found', [('Content-type', 'text/plain')])
200                return [b'File not found']
201                
202        elif path == f"/{file_uuid}/upload" and method == 'GET':
203            # GET: Serve the upload form
204            start_response('200 OK', [('Content-type', 'text/html')])
205            return [upload_form.encode()]
206            
207        elif path == f"/{file_uuid}/upload" and method == 'POST':
208            # POST: Handle file uploads
209            try:
210                # Get content length
211                content_length = int(environ.get('CONTENT_LENGTH', 0))
212                
213                # Get content type and boundary
214                content_type = environ.get('CONTENT_TYPE', '')
215                
216                # Read the request body
217                request_body = environ['wsgi.input'].read(content_length)
218                
219                # Create a file-like object from the request body
220                # request_body_file = io.BytesIO(request_body)
221                
222                # Parse the multipart form data using WSGI approach
223                # First, detect the boundary from content_type
224                boundary = None
225                for part in content_type.split(';'):
226                    part = part.strip()
227                    if part.startswith('boundary='):
228                        boundary = part[9:]
229                        if boundary.startswith('"') and boundary.endswith('"'):
230                            boundary = boundary[1:-1]
231                        break
232                
233                if not boundary:
234                    start_response('400 Bad Request', [('Content-type', 'text/plain')])
235                    return [b"Missing boundary in multipart form data"]
236                
237                # Process multipart data
238                parts = request_body.split(f'--{boundary}'.encode())
239                
240                # Initialize variables to store form data
241                upload_type = None
242                # file_item = None
243                file_data = None
244                filename = None
245                
246                # Process each part
247                for part in parts:
248                    if not part.strip():
249                        continue
250                    
251                    # Split header and body
252                    try:
253                        headers_raw, body = part.split(b'\r\n\r\n', 1)
254                        headers_text = headers_raw.decode('utf-8')
255                    except ValueError:
256                        continue
257                    
258                    # Parse headers
259                    headers = {}
260                    for header_line in headers_text.split('\r\n'):
261                        if ':' in header_line:
262                            name, value = header_line.split(':', 1)
263                            headers[name.strip().lower()] = value.strip()
264                    
265                    # Get content disposition
266                    content_disposition = headers.get('content-disposition', '')
267                    if not content_disposition.startswith('form-data'):
268                        continue
269                    
270                    # Extract field name
271                    field_name = None
272                    for item in content_disposition.split(';'):
273                        item = item.strip()
274                        if item.startswith('name='):
275                            field_name = item[5:].strip('"\'')
276                            break
277                    
278                    if not field_name:
279                        continue
280                    
281                    # Handle upload_type field
282                    if field_name == 'upload_type':
283                        # Remove trailing data including the boundary
284                        body_end = body.find(b'\r\n--')
285                        if body_end >= 0:
286                            body = body[:body_end]
287                        upload_type = body.decode('utf-8').strip()
288                    
289                    # Handle file field
290                    elif field_name == 'file':
291                        # Extract filename
292                        for item in content_disposition.split(';'):
293                            item = item.strip()
294                            if item.startswith('filename='):
295                                filename = item[9:].strip('"\'')
296                                break
297                        
298                        if filename:
299                            # Remove trailing data including the boundary
300                            body_end = body.find(b'\r\n--')
301                            if body_end >= 0:
302                                body = body[:body_end]
303                            file_data = body
304                
305                if debug:
306                    print('upload_type', upload_type)
307                    
308                if debug:
309                    print('upload_type:', upload_type)
310                    print('filename:', filename)
311                
312                if not upload_type or upload_type not in [FileType.Database.value, FileType.CSV.value]:
313                    start_response('400 Bad Request', [('Content-type', 'text/plain')])
314                    return [b"Invalid upload type"]
315                
316                if not filename or not file_data:
317                    start_response('400 Bad Request', [('Content-type', 'text/plain')])
318                    return [b"Missing file data"]
319                
320                if debug:
321                    print(f'Uploaded filename: {filename}')
322                
323                # Save the file
324                file_path = os.path.join(upload_directory, upload_type)
325                with open(file_path, 'wb') as f:
326                    f.write(file_data)
327                
328                # Process based on file type
329                if upload_type == FileType.Database.value:
330                    try:
331                        # Verify database file
332                        if database_callback is not None:
333                            database_callback(file_path)
334                        
335                        # Copy database into the original path
336                        shutil.copy2(file_path, database_path)
337                        
338                        start_response('200 OK', [('Content-type', 'text/plain')])
339                        return [b"Database file uploaded successfully."]
340                    except Exception as e:
341                        start_response('400 Bad Request', [('Content-type', 'text/plain')])
342                        return [str(e).encode()]
343                
344                elif upload_type == FileType.CSV.value:
345                    try:
346                        if csv_callback is not None:
347                            result = csv_callback(file_path, database_path, debug)
348                            if debug:
349                                print(f'CSV imported: {result}')
350                            if len(result[2]) != 0:
351                                start_response('200 OK', [('Content-type', 'application/json')])
352                                return [json.dumps(result).encode()]
353                        
354                        start_response('200 OK', [('Content-type', 'text/plain')])
355                        return [b"CSV file uploaded successfully."]
356                    except Exception as e:
357                        start_response('400 Bad Request', [('Content-type', 'text/plain')])
358                        return [str(e).encode()]
359            
360            except Exception as e:
361                start_response('500 Internal Server Error', [('Content-type', 'text/plain')])
362                return [f"Error processing upload: {str(e)}".encode()]
363        
364        else:
365            # 404 for anything else
366            start_response('404 Not Found', [('Content-type', 'text/plain')])
367            return [b'Not Found']
368    
369    # Create and start the server
370    httpd = make_server('localhost', port, wsgi_app)
371    server_thread = threading.Thread(target=httpd.serve_forever)
372    
373    def shutdown_server():
374        nonlocal httpd, server_thread
375        httpd.shutdown()
376        server_thread.join()  # Wait for the thread to finish
377    
378    return file_name, download_url, upload_url, server_thread, shutdown_server

Starts a multi-purpose WSGI server to manage file interactions for a Zakat application.

This server facilitates the following functionalities:

  1. GET /{file_uuid}/get: Download the database file specified by database_path.
  2. GET /{file_uuid}/upload: Display an HTML form for uploading files.
  3. POST /{file_uuid}/upload: Handle file uploads, distinguishing between:
    • Database File (.db): Replaces the existing database with the uploaded one.
    • CSV File (.csv): Imports data from the CSV into the existing database.

Parameters:

  • database_path (str): The path to the camel database file.
  • database_callback (callable, optional): A function to call after a successful database upload. It receives the uploaded database path as its argument.
  • csv_callback (callable, optional): A function to call after a successful CSV upload. It receives the uploaded CSV path, the database path, and the debug flag as its arguments.
  • debug (bool, optional): If True, print debugging information. Defaults to False.

Returns:

  • Tuple[str, str, str, threading.Thread, Callable[[], None]]: A tuple containing:
    • file_name (str): The name of the database file.
    • download_url (str): The URL to download the database file.
    • upload_url (str): The URL to access the file upload form.
    • server_thread (threading.Thread): The thread running the server.
    • shutdown_server (Callable[[], None]): A function to gracefully shut down the server.

Example:

_, download_url, upload_url, server_thread, shutdown_server = start_file_server("zakat.db")
print(f"Download database: {download_url}")
print(f"Upload files: {upload_url}")
server_thread.start()
# ... later ...
shutdown_server()
def find_available_port() -> int:
 85def find_available_port() -> int:
 86    """
 87    Finds and returns an available TCP port on the local machine.
 88
 89    This function utilizes a TCP server socket to bind to port 0, which
 90    instructs the operating system to automatically assign an available
 91    port. The assigned port is then extracted and returned.
 92
 93    Returns:
 94    - int: The available TCP port number.
 95
 96    Raises:
 97    - OSError: If an error occurs during the port binding process, such
 98            as all ports being in use.
 99
100    Example:
101    ```python
102    port = find_available_port()
103    print(f"Available port: {port}")
104    ```
105    """
106    with socketserver.TCPServer(("localhost", 0), None) as s:
107        return s.server_address[1]

Finds and returns an available TCP port on the local machine.

This function utilizes a TCP server socket to bind to port 0, which instructs the operating system to automatically assign an available port. The assigned port is then extracted and returned.

Returns:

  • int: The available TCP port number.

Raises:

  • OSError: If an error occurs during the port binding process, such as all ports being in use.

Example:

port = find_available_port()
print(f"Available port: {port}")
@enum.unique
class FileType(enum.Enum):
42@enum.unique
43class FileType(enum.Enum):
44    """
45    Enumeration representing file types.
46
47    Members:
48    - Database: Represents a database file ('db').
49    - CSV: Represents a CSV file ('csv').
50    """
51    Database = 'db'
52    CSV = 'csv'

Enumeration representing file types.

Members:

  • Database: Represents a database file ('db').
  • CSV: Represents a CSV file ('csv').
Database = <FileType.Database: 'db'>
CSV = <FileType.CSV: 'csv'>