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    SizeInfo,
 21    FileInfo,
 22    FileStats,
 23    TimeSummary,
 24    Transaction,
 25    DailyRecords,
 26    Timeline,
 27    ImportStatistics,
 28    CSVRecord,
 29    ImportReport,
 30    ZakatTracker,
 31    AccountID,
 32    AccountDetails,
 33    Timestamp,
 34    Box,
 35    Log,
 36    Account,
 37    Exchange,
 38    History,
 39    Vault,
 40    AccountPaymentPart,
 41    PaymentParts,
 42    SubtractAge,
 43    SubtractAges,
 44    SubtractReport,
 45    TransferTime,
 46    TransferTimes,
 47    TransferRecord,
 48    TransferReport,
 49    BoxPlan,
 50    ZakatSummary,
 51    ZakatReport,
 52    test,
 53    Action,
 54    JSONEncoder,
 55    JSONDecoder,
 56    MathOperation,
 57    WeekDay,
 58    StrictDataclass,
 59    ImmutableWithSelectiveFreeze,
 60    Backup,
 61)
 62
 63from zakat.file_server import (
 64    start_file_server,
 65    find_available_port,
 66    FileType,
 67)
 68
 69# Shortcuts
 70time = Time.time
 71time_to_datetime = Time.time_to_datetime
 72tracker = ZakatTracker
 73
 74# Version information for the module
 75__version__ = ZakatTracker.Version()
 76__all__ = [
 77    "Time",
 78    "time",
 79    "time_to_datetime",
 80    "tracker",
 81    "SizeInfo",
 82    "FileInfo",
 83    "FileStats",
 84    "TimeSummary",
 85    "Transaction",
 86    "DailyRecords",
 87    "Timeline",
 88    "ImportStatistics",
 89    "CSVRecord",
 90    "ImportReport",
 91    "ZakatTracker",
 92    "AccountID",
 93    "AccountDetails",
 94    "Timestamp",
 95    "Box",
 96    "Log",
 97    "Account",
 98    "Exchange",
 99    "History",
100    "Vault",
101    "AccountPaymentPart",
102    "PaymentParts",
103    "SubtractAge",
104    "SubtractAges",
105    "SubtractReport",
106    "TransferTime",
107    "TransferTimes",
108    "TransferRecord",
109    "TransferReport",
110    "BoxPlan",
111    "ZakatSummary",
112    "ZakatReport",
113    "test",
114    "Action",
115    "JSONEncoder",
116    "JSONDecoder",
117    "MathOperation",
118    "WeekDay",
119    "start_file_server",
120    "find_available_port",
121    "FileType",
122    "StrictDataclass",
123    "ImmutableWithSelectiveFreeze",
124    "Backup",
125]
class Time:
1101class Time:
1102    """
1103    Utility class for generating and manipulating nanosecond-precision timestamps.
1104
1105    This class provides static methods for converting between datetime objects and
1106    nanosecond-precision timestamps, ensuring uniqueness and monotonicity.
1107    """
1108    __last_time_ns = None
1109    __time_diff_ns = None
1110
1111    @staticmethod
1112    def minimum_time_diff_ns() -> tuple[int, int]:
1113        """
1114        Calculates the minimum time difference between two consecutive calls to
1115        `Time._time()` in nanoseconds.
1116
1117        This method is used internally to determine the minimum granularity of
1118        time measurements within the system.
1119
1120        Returns:
1121        - tuple[int, int]:
1122            - The minimum time difference in nanoseconds.
1123            - The number of iterations required to measure the difference.
1124        """
1125        i = 0
1126        x = y = Time._time()
1127        while x == y:
1128            y = Time._time()
1129            i += 1
1130        return y - x, i
1131
1132    @staticmethod
1133    def _time(now: Optional[datetime.datetime] = None) -> Timestamp:
1134        """
1135        Internal method to generate a nanosecond-precision timestamp from a datetime object.
1136
1137        Parameters:
1138        - now (datetime.datetime, optional): The datetime object to generate the timestamp from.
1139        If not provided, the current datetime is used.
1140
1141        Returns:
1142        - int: The timestamp in nanoseconds since the epoch (January 1, 1AD).
1143        """
1144        if now is None:
1145            now = datetime.datetime.now()
1146        ns_in_day = (now - now.replace(
1147            hour=0,
1148            minute=0,
1149            second=0,
1150            microsecond=0,
1151        )).total_seconds() * 10 ** 9
1152        return Timestamp(int(now.toordinal() * 86_400_000_000_000 + ns_in_day))
1153
1154    @staticmethod
1155    def time(now: Optional[datetime.datetime] = None) -> Timestamp:
1156        """
1157        Generates a unique, monotonically increasing timestamp based on the provided
1158        datetime object or the current datetime.
1159
1160        This method ensures that timestamps are unique even if called in rapid succession
1161        by introducing a small delay if necessary, based on the system's minimum
1162        time resolution.
1163
1164        Parameters:
1165        - now (datetime.datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used.
1166
1167        Returns:
1168        - Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD).
1169        """
1170        new_time = Time._time(now)
1171        if Time.__last_time_ns is None:
1172            Time.__last_time_ns = new_time
1173            return new_time
1174        while new_time == Time.__last_time_ns:
1175            if Time.__time_diff_ns is None:
1176                diff, _ = Time.minimum_time_diff_ns()
1177                Time.__time_diff_ns = math.ceil(diff)
1178            time.sleep(Time.__time_diff_ns / 1_000_000_000)
1179            new_time = Time._time()
1180        Time.__last_time_ns = new_time
1181        return new_time
1182
1183    @staticmethod
1184    def time_to_datetime(ordinal_ns: Timestamp) -> datetime.datetime:
1185        """
1186        Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD)
1187        back to a datetime object.
1188
1189        Parameters:
1190        - ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD).
1191
1192        Returns:
1193        - datetime.datetime: The corresponding datetime object.
1194        """
1195        d = datetime.datetime.fromordinal(ordinal_ns // 86_400_000_000_000)
1196        t = datetime.timedelta(seconds=(ordinal_ns % 86_400_000_000_000) // 10 ** 9)
1197        return datetime.datetime.combine(d, datetime.time()) + t
1198
1199    @staticmethod
1200    def duration_from_nanoseconds(ns: int,
1201                                  show_zeros_in_spoken_time: bool = False,
1202                                  spoken_time_separator=',',
1203                                  millennia: str = 'Millennia',
1204                                  century: str = 'Century',
1205                                  years: str = 'Years',
1206                                  days: str = 'Days',
1207                                  hours: str = 'Hours',
1208                                  minutes: str = 'Minutes',
1209                                  seconds: str = 'Seconds',
1210                                  milli_seconds: str = 'MilliSeconds',
1211                                  micro_seconds: str = 'MicroSeconds',
1212                                  nano_seconds: str = 'NanoSeconds',
1213                                  ) -> tuple:
1214        """
1215        REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
1216        Convert NanoSeconds to Human Readable Time Format.
1217        A NanoSeconds is a unit of time in the International System of Units (SI) equal
1218        to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
1219        Its symbol is μs, sometimes simplified to us when Unicode is not available.
1220        A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
1221
1222        INPUT : ms (AKA: MilliSeconds)
1223        OUTPUT: tuple(string time_lapsed, string spoken_time) like format.
1224        OUTPUT Variables: time_lapsed, spoken_time
1225
1226        Example  Input: duration_from_nanoseconds(ns)
1227        **'Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds'**
1228        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')
1229        duration_from_nanoseconds(1234567890123456789012)
1230        """
1231        us, ns = divmod(ns, 1000)
1232        ms, us = divmod(us, 1000)
1233        s, ms = divmod(ms, 1000)
1234        m, s = divmod(s, 60)
1235        h, m = divmod(m, 60)
1236        d, h = divmod(h, 24)
1237        y, d = divmod(d, 365)
1238        c, y = divmod(y, 100)
1239        n, c = divmod(c, 10)
1240        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}'
1241        spoken_time_part = []
1242        if n > 0 or show_zeros_in_spoken_time:
1243            spoken_time_part.append(f'{n: 3d} {millennia}')
1244        if c > 0 or show_zeros_in_spoken_time:
1245            spoken_time_part.append(f'{c: 4d} {century}')
1246        if y > 0 or show_zeros_in_spoken_time:
1247            spoken_time_part.append(f'{y: 3d} {years}')
1248        if d > 0 or show_zeros_in_spoken_time:
1249            spoken_time_part.append(f'{d: 4d} {days}')
1250        if h > 0 or show_zeros_in_spoken_time:
1251            spoken_time_part.append(f'{h: 2d} {hours}')
1252        if m > 0 or show_zeros_in_spoken_time:
1253            spoken_time_part.append(f'{m: 2d} {minutes}')
1254        if s > 0 or show_zeros_in_spoken_time:
1255            spoken_time_part.append(f'{s: 2d} {seconds}')
1256        if ms > 0 or show_zeros_in_spoken_time:
1257            spoken_time_part.append(f'{ms: 3d} {milli_seconds}')
1258        if us > 0 or show_zeros_in_spoken_time:
1259            spoken_time_part.append(f'{us: 3d} {micro_seconds}')
1260        if ns > 0 or show_zeros_in_spoken_time:
1261            spoken_time_part.append(f'{ns: 3d} {nano_seconds}')
1262        return time_lapsed, spoken_time_separator.join(spoken_time_part)
1263
1264    @staticmethod
1265    def test(debug: bool = False):
1266        """
1267        Performs unit tests to verify the correctness of the `Time` class methods.
1268
1269        This method checks the conversion between datetime objects and timestamps,
1270        ensuring accuracy and consistency across various date ranges.
1271
1272        Parameters:
1273        - debug (bool, optional): If True, prints the timestamp and converted datetime for each test case. Defaults to False.
1274        """
1275        test_cases = [
1276            datetime.datetime(1, 1, 1),
1277            datetime.datetime(1970, 1, 1),
1278            datetime.datetime(1969, 12, 31),
1279            datetime.datetime.now(),
1280            datetime.datetime(9999, 12, 31, 23, 59, 59),
1281        ]
1282
1283        for test_date in test_cases:
1284            timestamp = Time.time(test_date)
1285            converted = Time.time_to_datetime(timestamp)
1286            if debug:
1287                print(f'{timestamp} <=> {converted}')
1288            assert timestamp > 0
1289            assert test_date.year == converted.year
1290            assert test_date.month == converted.month
1291            assert test_date.day == converted.day
1292            assert test_date.hour == converted.hour
1293            assert test_date.minute == converted.minute
1294            assert test_date.second in [converted.second - 1, converted.second, converted.second + 1]
1295
1296        # sanity check - convert date since 1AD to 9999AD
1297
1298        for year in range(1, 10_000):
1299            ns = Time.time(datetime.datetime.strptime(f'{year:04d}-12-30 18:30:45.906030', '%Y-%m-%d %H:%M:%S.%f'))
1300            date = Time.time_to_datetime(ns)
1301            if debug:
1302                print(date, date.microsecond)
1303            assert ns > 0
1304            assert date.year == year
1305            assert date.month == 12
1306            assert date.day == 30
1307            assert date.hour == 18
1308            assert date.minute == 30
1309            assert date.second in [44, 45]
1310            #assert date.microsecond == 906030

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]:
1111    @staticmethod
1112    def minimum_time_diff_ns() -> tuple[int, int]:
1113        """
1114        Calculates the minimum time difference between two consecutive calls to
1115        `Time._time()` in nanoseconds.
1116
1117        This method is used internally to determine the minimum granularity of
1118        time measurements within the system.
1119
1120        Returns:
1121        - tuple[int, int]:
1122            - The minimum time difference in nanoseconds.
1123            - The number of iterations required to measure the difference.
1124        """
1125        i = 0
1126        x = y = Time._time()
1127        while x == y:
1128            y = Time._time()
1129            i += 1
1130        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:
1154    @staticmethod
1155    def time(now: Optional[datetime.datetime] = None) -> Timestamp:
1156        """
1157        Generates a unique, monotonically increasing timestamp based on the provided
1158        datetime object or the current datetime.
1159
1160        This method ensures that timestamps are unique even if called in rapid succession
1161        by introducing a small delay if necessary, based on the system's minimum
1162        time resolution.
1163
1164        Parameters:
1165        - now (datetime.datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used.
1166
1167        Returns:
1168        - Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD).
1169        """
1170        new_time = Time._time(now)
1171        if Time.__last_time_ns is None:
1172            Time.__last_time_ns = new_time
1173            return new_time
1174        while new_time == Time.__last_time_ns:
1175            if Time.__time_diff_ns is None:
1176                diff, _ = Time.minimum_time_diff_ns()
1177                Time.__time_diff_ns = math.ceil(diff)
1178            time.sleep(Time.__time_diff_ns / 1_000_000_000)
1179            new_time = Time._time()
1180        Time.__last_time_ns = new_time
1181        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:
1183    @staticmethod
1184    def time_to_datetime(ordinal_ns: Timestamp) -> datetime.datetime:
1185        """
1186        Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD)
1187        back to a datetime object.
1188
1189        Parameters:
1190        - ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD).
1191
1192        Returns:
1193        - datetime.datetime: The corresponding datetime object.
1194        """
1195        d = datetime.datetime.fromordinal(ordinal_ns // 86_400_000_000_000)
1196        t = datetime.timedelta(seconds=(ordinal_ns % 86_400_000_000_000) // 10 ** 9)
1197        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:
1199    @staticmethod
1200    def duration_from_nanoseconds(ns: int,
1201                                  show_zeros_in_spoken_time: bool = False,
1202                                  spoken_time_separator=',',
1203                                  millennia: str = 'Millennia',
1204                                  century: str = 'Century',
1205                                  years: str = 'Years',
1206                                  days: str = 'Days',
1207                                  hours: str = 'Hours',
1208                                  minutes: str = 'Minutes',
1209                                  seconds: str = 'Seconds',
1210                                  milli_seconds: str = 'MilliSeconds',
1211                                  micro_seconds: str = 'MicroSeconds',
1212                                  nano_seconds: str = 'NanoSeconds',
1213                                  ) -> tuple:
1214        """
1215        REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
1216        Convert NanoSeconds to Human Readable Time Format.
1217        A NanoSeconds is a unit of time in the International System of Units (SI) equal
1218        to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
1219        Its symbol is μs, sometimes simplified to us when Unicode is not available.
1220        A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
1221
1222        INPUT : ms (AKA: MilliSeconds)
1223        OUTPUT: tuple(string time_lapsed, string spoken_time) like format.
1224        OUTPUT Variables: time_lapsed, spoken_time
1225
1226        Example  Input: duration_from_nanoseconds(ns)
1227        **'Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds'**
1228        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')
1229        duration_from_nanoseconds(1234567890123456789012)
1230        """
1231        us, ns = divmod(ns, 1000)
1232        ms, us = divmod(us, 1000)
1233        s, ms = divmod(ms, 1000)
1234        m, s = divmod(s, 60)
1235        h, m = divmod(m, 60)
1236        d, h = divmod(h, 24)
1237        y, d = divmod(d, 365)
1238        c, y = divmod(y, 100)
1239        n, c = divmod(c, 10)
1240        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}'
1241        spoken_time_part = []
1242        if n > 0 or show_zeros_in_spoken_time:
1243            spoken_time_part.append(f'{n: 3d} {millennia}')
1244        if c > 0 or show_zeros_in_spoken_time:
1245            spoken_time_part.append(f'{c: 4d} {century}')
1246        if y > 0 or show_zeros_in_spoken_time:
1247            spoken_time_part.append(f'{y: 3d} {years}')
1248        if d > 0 or show_zeros_in_spoken_time:
1249            spoken_time_part.append(f'{d: 4d} {days}')
1250        if h > 0 or show_zeros_in_spoken_time:
1251            spoken_time_part.append(f'{h: 2d} {hours}')
1252        if m > 0 or show_zeros_in_spoken_time:
1253            spoken_time_part.append(f'{m: 2d} {minutes}')
1254        if s > 0 or show_zeros_in_spoken_time:
1255            spoken_time_part.append(f'{s: 2d} {seconds}')
1256        if ms > 0 or show_zeros_in_spoken_time:
1257            spoken_time_part.append(f'{ms: 3d} {milli_seconds}')
1258        if us > 0 or show_zeros_in_spoken_time:
1259            spoken_time_part.append(f'{us: 3d} {micro_seconds}')
1260        if ns > 0 or show_zeros_in_spoken_time:
1261            spoken_time_part.append(f'{ns: 3d} {nano_seconds}')
1262        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):
1264    @staticmethod
1265    def test(debug: bool = False):
1266        """
1267        Performs unit tests to verify the correctness of the `Time` class methods.
1268
1269        This method checks the conversion between datetime objects and timestamps,
1270        ensuring accuracy and consistency across various date ranges.
1271
1272        Parameters:
1273        - debug (bool, optional): If True, prints the timestamp and converted datetime for each test case. Defaults to False.
1274        """
1275        test_cases = [
1276            datetime.datetime(1, 1, 1),
1277            datetime.datetime(1970, 1, 1),
1278            datetime.datetime(1969, 12, 31),
1279            datetime.datetime.now(),
1280            datetime.datetime(9999, 12, 31, 23, 59, 59),
1281        ]
1282
1283        for test_date in test_cases:
1284            timestamp = Time.time(test_date)
1285            converted = Time.time_to_datetime(timestamp)
1286            if debug:
1287                print(f'{timestamp} <=> {converted}')
1288            assert timestamp > 0
1289            assert test_date.year == converted.year
1290            assert test_date.month == converted.month
1291            assert test_date.day == converted.day
1292            assert test_date.hour == converted.hour
1293            assert test_date.minute == converted.minute
1294            assert test_date.second in [converted.second - 1, converted.second, converted.second + 1]
1295
1296        # sanity check - convert date since 1AD to 9999AD
1297
1298        for year in range(1, 10_000):
1299            ns = Time.time(datetime.datetime.strptime(f'{year:04d}-12-30 18:30:45.906030', '%Y-%m-%d %H:%M:%S.%f'))
1300            date = Time.time_to_datetime(ns)
1301            if debug:
1302                print(date, date.microsecond)
1303            assert ns > 0
1304            assert date.year == year
1305            assert date.month == 12
1306            assert date.day == 30
1307            assert date.hour == 18
1308            assert date.minute == 30
1309            assert date.second in [44, 45]
1310            #assert date.microsecond == 906030

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:
1154    @staticmethod
1155    def time(now: Optional[datetime.datetime] = None) -> Timestamp:
1156        """
1157        Generates a unique, monotonically increasing timestamp based on the provided
1158        datetime object or the current datetime.
1159
1160        This method ensures that timestamps are unique even if called in rapid succession
1161        by introducing a small delay if necessary, based on the system's minimum
1162        time resolution.
1163
1164        Parameters:
1165        - now (datetime.datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used.
1166
1167        Returns:
1168        - Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD).
1169        """
1170        new_time = Time._time(now)
1171        if Time.__last_time_ns is None:
1172            Time.__last_time_ns = new_time
1173            return new_time
1174        while new_time == Time.__last_time_ns:
1175            if Time.__time_diff_ns is None:
1176                diff, _ = Time.minimum_time_diff_ns()
1177                Time.__time_diff_ns = math.ceil(diff)
1178            time.sleep(Time.__time_diff_ns / 1_000_000_000)
1179            new_time = Time._time()
1180        Time.__last_time_ns = new_time
1181        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:
1183    @staticmethod
1184    def time_to_datetime(ordinal_ns: Timestamp) -> datetime.datetime:
1185        """
1186        Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD)
1187        back to a datetime object.
1188
1189        Parameters:
1190        - ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD).
1191
1192        Returns:
1193        - datetime.datetime: The corresponding datetime object.
1194        """
1195        d = datetime.datetime.fromordinal(ordinal_ns // 86_400_000_000_000)
1196        t = datetime.timedelta(seconds=(ordinal_ns % 86_400_000_000_000) // 10 ** 9)
1197        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'>
@dataclasses.dataclass
class SizeInfo(zakat.StrictDataclass):
808@dataclasses.dataclass
809class SizeInfo(StrictDataclass):
810    """
811    Represents size information in bytes and human-readable format.
812    
813    Attributes:
814    - bytes (float): The size in bytes.
815    - human_readable (str): The human-readable representation of the size.
816    """
817    bytes: float
818    human_readable: str

Represents size information in bytes and human-readable format.

Attributes:

  • bytes (float): The size in bytes.
  • human_readable (str): The human-readable representation of the size.
SizeInfo(bytes: float, human_readable: str)
bytes: float
human_readable: str
@dataclasses.dataclass
class FileInfo(zakat.StrictDataclass):
821@dataclasses.dataclass
822class FileInfo(StrictDataclass):
823    """
824    Represents information about a file.
825    
826    Attributes:
827    - type (str): The type of the file.
828    - path (str): The full path to the file.
829    - exists (bool): A boolean indicating whether the file exists.
830    - size (int): The size of the file in bytes.
831    - human_readable_size (str): The human-readable representation of the file size.
832    """
833    type: str
834    path: str
835    exists: bool
836    size: int
837    human_readable_size: str

Represents information about a file.

Attributes:

  • type (str): The type of the file.
  • path (str): The full path to the file.
  • exists (bool): A boolean indicating whether the file exists.
  • size (int): The size of the file in bytes.
  • human_readable_size (str): The human-readable representation of the file size.
FileInfo( type: str, path: str, exists: bool, size: int, human_readable_size: str)
type: str
path: str
exists: bool
size: int
human_readable_size: str
@dataclasses.dataclass
class FileStats(zakat.StrictDataclass):
840@dataclasses.dataclass
841class FileStats(StrictDataclass):
842    """
843    Represents statistics related to file storage.
844    
845    Attributes:
846    - ram (:class:`SizeInfo`): Information about the RAM usage.
847    - database (:class:`SizeInfo`): Information about the database size.
848    """
849    ram: SizeInfo
850    database: SizeInfo

Represents statistics related to file storage.

Attributes:

  • ram (SizeInfo): Information about the RAM usage.
  • database (SizeInfo): Information about the database size.
FileStats( ram: SizeInfo, database: SizeInfo)
ram: SizeInfo
database: SizeInfo
@dataclasses.dataclass
class TimeSummary(zakat.StrictDataclass):
853@dataclasses.dataclass
854class TimeSummary(StrictDataclass):
855    """Summary of positive, negative, and total values over a period."""
856    positive: int = 0
857    negative: int = 0
858    total: int = 0

Summary of positive, negative, and total values over a period.

TimeSummary(positive: int = 0, negative: int = 0, total: int = 0)
positive: int = 0
negative: int = 0
total: int = 0
@dataclasses.dataclass
class Transaction(zakat.StrictDataclass):
861@dataclasses.dataclass
862class Transaction(StrictDataclass):
863    """Represents a single transaction record."""
864    account: str
865    account_id: AccountID
866    desc: str
867    file: dict[Timestamp, str]
868    value: int
869    time: Timestamp
870    transfer: bool

Represents a single transaction record.

Transaction( account: str, account_id: AccountID, desc: str, file: dict[Timestamp, str], value: int, time: Timestamp, transfer: bool)
account: str
account_id: AccountID
desc: str
file: dict[Timestamp, str]
value: int
time: Timestamp
transfer: bool
@dataclasses.dataclass
class DailyRecords(zakat.TimeSummary, zakat.StrictDataclass):
873@dataclasses.dataclass
874class DailyRecords(TimeSummary, StrictDataclass):
875    """Represents the records for a single day, including a summary and a list of transactions."""
876    rows: list[Transaction] = dataclasses.field(default_factory=list)

Represents the records for a single day, including a summary and a list of transactions.

DailyRecords( positive: int = 0, negative: int = 0, total: int = 0, rows: list[Transaction] = <factory>)
rows: list[Transaction]
Inherited Members
TimeSummary
positive
negative
total
@dataclasses.dataclass
class Timeline(zakat.StrictDataclass):
879@dataclasses.dataclass
880class Timeline(StrictDataclass):
881    """Aggregated transaction data organized by daily, weekly, monthly, and yearly summaries."""
882    daily: dict[str, DailyRecords] = dataclasses.field(default_factory=dict)
883    weekly: dict[datetime.datetime, TimeSummary] = dataclasses.field(default_factory=dict)
884    monthly: dict[str, TimeSummary] = dataclasses.field(default_factory=dict)
885    yearly: dict[int, TimeSummary] = dataclasses.field(default_factory=dict)

Aggregated transaction data organized by daily, weekly, monthly, and yearly summaries.

Timeline( daily: dict[str, DailyRecords] = <factory>, weekly: dict[datetime.datetime, TimeSummary] = <factory>, monthly: dict[str, TimeSummary] = <factory>, yearly: dict[int, TimeSummary] = <factory>)
daily: dict[str, DailyRecords]
weekly: dict[datetime.datetime, TimeSummary]
monthly: dict[str, TimeSummary]
yearly: dict[int, TimeSummary]
@dataclasses.dataclass
class ImportStatistics(zakat.StrictDataclass):
752@dataclasses.dataclass
753class ImportStatistics(StrictDataclass):
754    """
755    Statistics summarizing the results of an import operation.
756
757    Attributes:
758    - created (int): The number of new records successfully created.
759    - found (int): The number of existing records found and potentially updated.
760    - bad (int): The number of records that failed to import due to errors.
761    """
762    created: int
763    found: int
764    bad: int

Statistics summarizing the results of an import operation.

Attributes:

  • created (int): The number of new records successfully created.
  • found (int): The number of existing records found and potentially updated.
  • bad (int): The number of records that failed to import due to errors.
ImportStatistics(created: int, found: int, bad: int)
created: int
found: int
bad: int
@dataclasses.dataclass
class CSVRecord(zakat.StrictDataclass):
767@dataclasses.dataclass
768class CSVRecord(StrictDataclass):
769    """
770    Represents a single record read from a CSV file.
771
772    Attributes:
773    - index (int): The original row number of the record in the CSV file (0-based).
774    - account (str): The account identifier.
775    - desc (str): A description associated with the record.
776    - value (int): The numerical value of the record.
777    - date (str): The date associated with the record (format may vary).
778    - rate (float): A rate or factor associated with the record.
779    - reference (str): An optional reference string.
780    - hashed (str): A hashed representation of the record's content.
781    - error (str): An error message if there was an issue processing this record.
782    """
783    index: int
784    account: str
785    desc: str
786    value: int
787    date: str
788    rate: float
789    reference: str
790    hashed: str
791    error: str

Represents a single record read from a CSV file.

Attributes:

  • index (int): The original row number of the record in the CSV file (0-based).
  • account (str): The account identifier.
  • desc (str): A description associated with the record.
  • value (int): The numerical value of the record.
  • date (str): The date associated with the record (format may vary).
  • rate (float): A rate or factor associated with the record.
  • reference (str): An optional reference string.
  • hashed (str): A hashed representation of the record's content.
  • error (str): An error message if there was an issue processing this record.
CSVRecord( index: int, account: str, desc: str, value: int, date: str, rate: float, reference: str, hashed: str, error: str)
index: int
account: str
desc: str
value: int
date: str
rate: float
reference: str
hashed: str
error: str
@dataclasses.dataclass
class ImportReport(zakat.StrictDataclass):
794@dataclasses.dataclass
795class ImportReport(StrictDataclass):
796    """
797    A report summarizing the outcome of an import operation.
798
799    Attributes:
800    - statistics (ImportStatistics): Statistical information about the import.
801    - bad (list[CSVRecord]): A list of CSV records that failed to import,
802                                 including any error messages.
803    """
804    statistics: ImportStatistics
805    bad: list[CSVRecord]

A report summarizing the outcome of an import operation.

Attributes:

  • statistics (ImportStatistics): Statistical information about the import.
  • bad (list[CSVRecord]): A list of CSV records that failed to import, including any error messages.
ImportReport( statistics: ImportStatistics, bad: list[CSVRecord])
statistics: ImportStatistics
bad: list[CSVRecord]
class ZakatTracker:
1322class ZakatTracker:
1323    """
1324    A class for tracking and calculating Zakat.
1325
1326    This class provides functionalities for recording transactions, calculating Zakat due,
1327    and managing account balances. It also offers features like importing transactions from
1328    CSV files, exporting data to JSON format, and saving/loading the tracker state.
1329
1330    The `ZakatTracker` class is designed to handle both positive and negative transactions,
1331    allowing for flexible tracking of financial activities related to Zakat. It also supports
1332    the concept of a 'Nisab' (minimum threshold for Zakat) and a 'haul' (complete one year for Transaction) can calculate Zakat due
1333    based on the current silver price.
1334
1335    The class uses a json file as its database to persist the tracker state,
1336    ensuring data integrity across sessions. It also provides options for enabling or
1337    disabling history tracking, allowing users to choose their preferred level of detail.
1338
1339    In addition, the `ZakatTracker` class includes various helper methods like
1340    `time`, `time_to_datetime`, `lock`, `free`, `recall`, `save`, `load`
1341    and more. These methods provide additional functionalities and flexibility
1342    for interacting with and managing the Zakat tracker.
1343
1344    Attributes:
1345    - ZakatTracker.ZakatCut (function): A function to calculate the Zakat percentage.
1346    - ZakatTracker.TimeCycle (function): A function to determine the time cycle for Zakat.
1347    - ZakatTracker.Nisab (function): A function to calculate the Nisab based on the silver price.
1348    - ZakatTracker.Version (function): The version of the ZakatTracker class.
1349
1350    Data Structure:
1351    
1352    The ZakatTracker class utilizes a nested dataclasses structure called '__vault' to store and manage data, here below is just a demonstration:
1353
1354        __vault (dict):
1355            - account (dict):
1356                - {account_id} (dict):
1357                    - balance (int): The current balance of the account.
1358                    - name (str): The name of the account.
1359                    - created (int): The creation time for the account.
1360                    - box (dict): A dictionary storing transaction details.
1361                        - {timestamp} (dict):
1362                            - capital (int): The initial amount of the transaction.
1363                            - rest (int): The remaining amount after Zakat deductions and withdrawal.
1364                            - zakat (dict):
1365                                - count (int): The number of times Zakat has been calculated for this transaction.
1366                                - last (int): The timestamp of the last Zakat calculation.
1367                                - total (int): The total Zakat deducted from this transaction.
1368                    - count (int): The total number of transactions for the account.
1369                    - log (dict): A dictionary storing transaction logs.
1370                        - {timestamp} (dict):
1371                            - value (int): The transaction amount (positive or negative).
1372                            - desc (str): The description of the transaction.
1373                            - ref (int): The box reference (positive or None).
1374                            - file (dict): A dictionary storing file references associated with the transaction.
1375                    - hide (bool): Indicates whether the account is hidden or not.
1376                    - zakatable (bool): Indicates whether the account is subject to Zakat.
1377            - exchange (dict):
1378                - {account_id} (dict):
1379                    - {timestamps} (dict):
1380                        - rate (float): Exchange rate when compared to local currency.
1381                        - description (str): The description of the exchange rate.
1382            - history (dict):
1383                - {lock_timestamp} (dict): A list of dictionaries storing the history of actions performed.
1384                    - {order_timestamp} (dict):
1385                        - {action_dict} (dict):
1386                            - action (Action): The type of action (CREATE, TRACK, LOG, SUB, ADD_FILE, REMOVE_FILE, BOX_TRANSFER, EXCHANGE, REPORT, ZAKAT).
1387                            - account (str): The account reference associated with the action.
1388                            - ref (int): The reference number of the transaction.
1389                            - file (int): The reference number of the file (if applicable).
1390                            - key (str): The key associated with the action (e.g., 'rest', 'total').
1391                            - value (int): The value associated with the action.
1392                            - math (MathOperation): The mathematical operation performed (if applicable).
1393            - lock (int or None): The timestamp indicating the current lock status (None if not locked).
1394            - report (dict):
1395                - {timestamp} (tuple): A tuple storing Zakat report details.
1396    """
1397
1398    @staticmethod
1399    def Version() -> str:
1400        """
1401        Returns the current version of the software.
1402
1403        This function returns a string representing the current version of the software,
1404        including major, minor, and patch version numbers in the format 'X.Y.Z'.
1405
1406        Returns:
1407        - str: The current version of the software.
1408        """
1409        version = '0.3.5'
1410        git_hash, unstaged_count, commit_count_since_last_tag = get_git_status()
1411        if git_hash and (unstaged_count > 0 or commit_count_since_last_tag > 0):
1412            version += f".{commit_count_since_last_tag}dev{unstaged_count}+{git_hash}"
1413            print(version)
1414        return version
1415
1416    @staticmethod
1417    def ZakatCut(x: float) -> float:
1418        """
1419        Calculates the Zakat amount due on an asset.
1420
1421        This function calculates the zakat amount due on a given asset value over one lunar year.
1422        Zakat is an Islamic obligatory alms-giving, calculated as a fixed percentage of an individual's wealth
1423        that exceeds a certain threshold (Nisab).
1424
1425        Parameters:
1426        - x (float): The total value of the asset on which Zakat is to be calculated.
1427
1428        Returns:
1429        - float: The amount of Zakat due on the asset, calculated as 2.5% of the asset's value.
1430        """
1431        return 0.025 * x  # Zakat Cut in one Lunar Year
1432
1433    @staticmethod
1434    def TimeCycle(days: int = 355) -> int:
1435        """
1436        Calculates the approximate duration of a lunar year in nanoseconds.
1437
1438        This function calculates the approximate duration of a lunar year based on the given number of days.
1439        It converts the given number of days into nanoseconds for use in high-precision timing applications.
1440
1441        Parameters:
1442        - days (int, optional): The number of days in a lunar year. Defaults to 355,
1443              which is an approximation of the average length of a lunar year.
1444
1445        Returns:
1446        - int: The approximate duration of a lunar year in nanoseconds.
1447        """
1448        return int(60 * 60 * 24 * days * 1e9)  # Lunar Year in nanoseconds
1449
1450    @staticmethod
1451    def Nisab(gram_price: float, gram_quantity: float = 595) -> float:
1452        """
1453        Calculate the total value of Nisab (a unit of weight in Islamic jurisprudence) based on the given price per gram.
1454
1455        This function calculates the Nisab value, which is the minimum threshold of wealth,
1456        that makes an individual liable for paying Zakat.
1457        The Nisab value is determined by the equivalent value of a specific amount
1458        of gold or silver (currently 595 grams in silver) in the local currency.
1459
1460        Parameters:
1461        - gram_price (float): The price per gram of Nisab.
1462        - gram_quantity (float, optional): The quantity of grams in a Nisab. Default is 595 grams of silver.
1463
1464        Returns:
1465        - float: The total value of Nisab based on the given price per gram.
1466        """
1467        return gram_price * gram_quantity
1468
1469    @staticmethod
1470    def ext() -> str:
1471        """
1472        Returns the file extension used by the ZakatTracker class.
1473
1474        Parameters:
1475        None
1476
1477        Returns:
1478        - str: The file extension used by the ZakatTracker class, which is 'json'.
1479        """
1480        return 'json'
1481
1482    __base_path = pathlib.Path("")
1483    __vault_path = pathlib.Path("")
1484    __memory_mode = False
1485    __debug_output: list[any] = []
1486    __vault: Vault
1487
1488    def __init__(self, db_path: str = './zakat_db/', history_mode: bool = True):
1489        """
1490        Initialize ZakatTracker with database path and history mode.
1491
1492        Parameters:
1493        - db_path (str, optional): The path to the database  directory. Defaults to './zakat_db/'. Use ':memory:' for an in-memory database.
1494        - history_mode (bool, optional): The mode for tracking history. Default is True.
1495
1496        Returns:
1497        None
1498        """
1499        self.reset()
1500        self.__memory_mode = db_path == ':memory:'
1501        self.__history(history_mode)
1502        if not self.__memory_mode:
1503            self.path(f'{db_path}/db.{self.ext()}')
1504
1505    def memory_mode(self) -> bool:
1506        """
1507        Check if the ZakatTracker is operating in memory mode.
1508
1509        Returns:
1510        - bool: True if the database is in memory, False otherwise.
1511        """
1512        return self.__memory_mode
1513
1514    def path(self, path: Optional[str] = None) -> str:
1515        """
1516        Set or get the path to the database file.
1517
1518        If no path is provided, the current path is returned.
1519        If a path is provided, it is set as the new path.
1520        The function also creates the necessary directories if the provided path is a file.
1521
1522        Parameters:
1523        - path (str, optional): The new path to the database file. If not provided, the current path is returned.
1524
1525        Returns:
1526        - str: The current or new path to the database file.
1527        """
1528        if path is None:
1529            return str(self.__vault_path)
1530        self.__vault_path = pathlib.Path(path).resolve()
1531        base_path = pathlib.Path(path).resolve()
1532        if base_path.is_file() or base_path.suffix:
1533            base_path = base_path.parent
1534        base_path.mkdir(parents=True, exist_ok=True)
1535        self.__base_path = base_path
1536        return str(self.__vault_path)
1537
1538    def base_path(self, *args) -> str:
1539        """
1540        Generate a base path by joining the provided arguments with the existing base path.
1541
1542        Parameters:
1543        - *args (str): Variable length argument list of strings to be joined with the base path.
1544
1545        Returns:
1546        - str: The generated base path. If no arguments are provided, the existing base path is returned.
1547        """
1548        if not args:
1549            return str(self.__base_path)
1550        filtered_args = []
1551        ignored_filename = None
1552        for arg in args:
1553            if pathlib.Path(arg).suffix:
1554                ignored_filename = arg
1555            else:
1556                filtered_args.append(arg)
1557        base_path = pathlib.Path(self.__base_path)
1558        full_path = base_path.joinpath(*filtered_args)
1559        full_path.mkdir(parents=True, exist_ok=True)
1560        if ignored_filename is not None:
1561            return full_path.resolve() / ignored_filename  # Join with the ignored filename
1562        return str(full_path.resolve())
1563
1564    @staticmethod
1565    def scale(x: float | int | decimal.Decimal, decimal_places: int = 2) -> int:
1566        """
1567        Scales a numerical value by a specified power of 10, returning an integer.
1568
1569        This function is designed to handle various numeric types (`float`, `int`, or `decimal.Decimal`) and
1570        facilitate precise scaling operations, particularly useful in financial or scientific calculations.
1571
1572        Parameters:
1573        - x (float | int | decimal.Decimal): The numeric value to scale. Can be a floating-point number, integer, or decimal.
1574        - decimal_places (int, optional): The exponent for the scaling factor (10**y). Defaults to 2, meaning the input is scaled
1575            by a factor of 100 (e.g., converts 1.23 to 123).
1576
1577        Returns:
1578        - The scaled value, rounded to the nearest integer.
1579
1580        Raises:
1581        - TypeError: If the input `x` is not a valid numeric type.
1582
1583        Examples:
1584        ```bash
1585        >>> ZakatTracker.scale(3.14159)
1586        314
1587        >>> ZakatTracker.scale(1234, decimal_places=3)
1588        1234000
1589        >>> ZakatTracker.scale(decimal.Decimal('0.005'), decimal_places=4)
1590        50
1591        ```
1592        """
1593        if not isinstance(x, (float, int, decimal.Decimal)):
1594            raise TypeError(f'Input "{x}" must be a float, int, or decimal.Decimal.')
1595        return int(decimal.Decimal(f'{x:.{decimal_places}f}') * (10 ** decimal_places))
1596
1597    @staticmethod
1598    def unscale(x: int, return_type: type = float, decimal_places: int = 2) -> float | decimal.Decimal:
1599        """
1600        Unscales an integer by a power of 10.
1601
1602        Parameters:
1603        - x (int): The integer to unscale.
1604        - return_type (type, optional): The desired type for the returned value. Can be float, int, or decimal.Decimal. Defaults to float.
1605        - decimal_places (int, optional): The power of 10 to use. Defaults to 2.
1606
1607        Returns:
1608        - float | int | decimal.Decimal: The unscaled number, converted to the specified return_type.
1609
1610        Raises:
1611        - TypeError: If the return_type is not float or decimal.Decimal.
1612        """
1613        if return_type not in (float, decimal.Decimal):
1614            raise TypeError(f'Invalid return_type({return_type}). Supported types are float, int, and decimal.Decimal.')
1615        return round(return_type(x / (10 ** decimal_places)), decimal_places)
1616
1617    def reset(self) -> None:
1618        """
1619        Reset the internal data structure to its initial state.
1620
1621        Parameters:
1622        None
1623
1624        Returns:
1625        None
1626        """
1627        self.__vault = Vault()
1628
1629    def clean_history(self, lock: Optional[Timestamp] = None) -> int:
1630        """
1631        Cleans up the empty history records of actions performed on the ZakatTracker instance.
1632
1633        Parameters:
1634        - lock (Timestamp, optional): The lock ID is used to clean up the empty history.
1635            If not provided, it cleans up the empty history records for all locks.
1636
1637        Returns:
1638        - int: The number of locks cleaned up.
1639        """
1640        count = 0
1641        if lock in self.__vault.history:
1642            if len(self.__vault.history[lock]) <= 0:
1643                count += 1
1644                del self.__vault.history[lock]
1645            return count
1646        for key in self.__vault.history:
1647            if len(self.__vault.history[key]) <= 0:
1648                count += 1
1649                del self.__vault.history[key]
1650        return count
1651
1652    def __history(self, status: Optional[bool] = None) -> bool:
1653        """
1654        Enable or disable history tracking.
1655
1656        Parameters:
1657        - status (bool, optional): The status of history tracking. Default is True.
1658
1659        Returns:
1660        None
1661        """
1662        if status is not None:
1663            self.__history_mode = status
1664        return self.__history_mode
1665
1666    def __step(self, action: Optional[Action] = None,
1667                    account: Optional[AccountID] = None,
1668                    ref: Optional[Timestamp] = None,
1669                    file: Optional[Timestamp] = None,
1670                    value: Optional[any] = None, # !!!
1671                    key: Optional[str] = None,
1672                    math_operation: Optional[MathOperation] = None,
1673                    lock_once: bool = True,
1674                    debug: bool = False,
1675                ) -> Optional[Timestamp]:
1676        """
1677        This method is responsible for recording the actions performed on the ZakatTracker.
1678
1679        Parameters:
1680        - action (Action, optional): The type of action performed.
1681        - account (AccountID, optional): The account reference on which the action was performed.
1682        - ref (Optional, optional): The reference number of the action.
1683        - file (Timestamp, optional): The file reference number of the action.
1684        - value (any, optional): The value associated with the action.
1685        - key (str, optional): The key associated with the action.
1686        - math_operation (MathOperation, optional): The mathematical operation performed during the action.
1687        - lock_once (bool, optional): Indicates whether a lock should be acquired only once. Defaults to True.
1688        - debug (bool, optional): If True, the function will print debug information. Default is False.
1689
1690        Returns:
1691        - Optional[Timestamp]: The lock time of the recorded action. If no lock was performed, it returns 0.
1692        """
1693        if not self.__history():
1694            return None
1695        no_lock = self.nolock()
1696        lock = self.__vault.lock
1697        if no_lock:
1698            lock = self.__vault.lock = Time.time()
1699            self.__vault.history[lock] = {}
1700        if action is None:
1701            if lock_once:
1702                assert no_lock, 'forbidden: lock called twice!!!'
1703            return lock
1704        if debug:
1705             print_stack()
1706        assert lock is not None
1707        assert lock > 0
1708        assert account is None or action != Action.REPORT
1709        self.__vault.history[lock][Time.time()] = History(
1710            action=action,
1711            account=account,
1712            ref=ref,
1713            file=file,
1714            key=key,
1715            value=value,
1716            math=math_operation,
1717        )
1718        return lock
1719
1720    def nolock(self) -> bool:
1721        """
1722        Check if the vault lock is currently not set.
1723
1724        Parameters:
1725        None
1726
1727        Returns:
1728        - bool: True if the vault lock is not set, False otherwise.
1729        """
1730        return self.__vault.lock is None
1731
1732    def __lock(self) -> Optional[Timestamp]:
1733        """
1734        Acquires a lock, potentially repeatedly, by calling the internal `_step` method.
1735
1736        This method specifically invokes the `_step` method with `lock_once` set to `False`
1737        indicating that the lock should be acquired even if it was previously acquired.
1738        This is useful for ensuring a lock is held throughout a critical section of code
1739
1740        Returns:
1741        - Optional[Timestamp]: The status code or result returned by the `_step` method, indicating theoutcome of the lock acquisition attempt.
1742        """
1743        return self.__step(lock_once=False)
1744
1745    def lock(self) -> Optional[Timestamp]:
1746        """
1747        Acquires a lock on the ZakatTracker instance.
1748
1749        Parameters:
1750        None
1751
1752        Returns:
1753        - Optional[Timestamp]: The lock ID. This ID can be used to release the lock later.
1754        """
1755        return self.__step()
1756
1757    def steps(self) -> dict:
1758        """
1759        Returns a copy of the history of steps taken in the ZakatTracker.
1760
1761        The history is a dictionary where each key is a unique identifier for a step,
1762        and the corresponding value is a dictionary containing information about the step.
1763
1764        Parameters:
1765        None
1766
1767        Returns:
1768        - dict: A copy of the history of steps taken in the ZakatTracker.
1769        """
1770        return {
1771            lock: {
1772                timestamp: dataclasses.asdict(history)
1773                for timestamp, history in steps.items()
1774            }
1775            for lock, steps in self.__vault.history.items()
1776        }
1777
1778    def free(self, lock: Timestamp, auto_save: bool = True) -> bool:
1779        """
1780        Releases the lock on the database.
1781
1782        Parameters:
1783        - lock (Timestamp): The lock ID to be released.
1784        - auto_save (bool, optional): Whether to automatically save the database after releasing the lock.
1785
1786        Returns:
1787        - bool: True if the lock is successfully released and (optionally) saved, False otherwise.
1788        """
1789        if lock == self.__vault.lock:
1790            self.clean_history(lock)
1791            self.__vault.lock = None
1792            if auto_save and not self.memory_mode():
1793                return self.save(self.path())
1794            return True
1795        return False
1796
1797    def recall(self, dry: bool = True, lock: Optional[Timestamp] = None, debug: bool = False) -> bool:
1798        """
1799        Revert the last operation.
1800
1801        Parameters:
1802        - dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
1803        - lock (Timestamp, optional): An optional lock value to ensure the recall
1804                operation is performed on the expected history entry. If provided,
1805                it checks if the current lock and the most recent history key
1806                match the given lock value. Defaults to None.
1807        - debug (bool, optional): If True, the function will print debug information. Default is False.
1808
1809        Returns:
1810        - bool: True if the operation was successful, False otherwise.
1811        """
1812        if not self.nolock() or len(self.__vault.history) == 0:
1813            return False
1814        if len(self.__vault.history) <= 0:
1815            return False
1816        ref = sorted(self.__vault.history.keys())[-1]
1817        if debug:
1818            print('recall', ref)
1819        memory = sorted(self.__vault.history[ref], reverse=True)
1820        if debug:
1821            print(type(memory), 'memory', memory)
1822        if lock is not None:
1823            assert self.__vault.lock == lock, "Invalid current lock"
1824            assert ref == lock, "Invalid last lock"
1825            assert self.__history(), "History mode should be enabled, found off!!!"
1826        sub_positive_log_negative = 0
1827        for i in memory:
1828            x = self.__vault.history[ref][i]
1829            if debug:
1830                print(type(x), x)
1831            if x.action != Action.REPORT:
1832                assert x.account is not None
1833                if x.action != Action.EXCHANGE:
1834                    assert self.account_exists(x.account)
1835            match x.action:
1836                case Action.CREATE:
1837                    if debug:
1838                        print('account', self.__vault.account[x.account])
1839                    assert len(self.__vault.account[x.account].box) == 0
1840                    assert len(self.__vault.account[x.account].log) == 0
1841                    assert self.__vault.account[x.account].balance == 0
1842                    assert self.__vault.account[x.account].count == 0
1843                    assert self.__vault.account[x.account].name == ''
1844                    if dry:
1845                        continue
1846                    del self.__vault.account[x.account]
1847
1848                case Action.NAME:
1849                    assert x.value is not None
1850                    if dry:
1851                        continue
1852                    self.__vault.account[x.account].name = x.value
1853
1854                case Action.TRACK:
1855                    assert x.value is not None
1856                    assert x.ref is not None
1857                    if dry:
1858                        continue
1859                    self.__vault.account[x.account].balance -= x.value
1860                    self.__vault.account[x.account].count -= 1
1861                    del self.__vault.account[x.account].box[x.ref]
1862
1863                case Action.LOG:
1864                    assert x.ref in self.__vault.account[x.account].log
1865                    assert x.value is not None
1866                    if dry:
1867                        continue
1868                    if sub_positive_log_negative == -x.value:
1869                        self.__vault.account[x.account].count -= 1
1870                        sub_positive_log_negative = 0
1871                    box_ref = self.__vault.account[x.account].log[x.ref].ref
1872                    if not box_ref is None:
1873                        assert self.box_exists(x.account, box_ref)
1874                        box_value = self.__vault.account[x.account].log[x.ref].value
1875                        assert box_value < 0
1876
1877                        try:
1878                            self.__vault.account[x.account].box[box_ref].rest += -box_value
1879                        except TypeError:
1880                            self.__vault.account[x.account].box[box_ref].rest += decimal.Decimal(-box_value)
1881
1882                        try:
1883                            self.__vault.account[x.account].balance += -box_value
1884                        except TypeError:
1885                            self.__vault.account[x.account].balance += decimal.Decimal(-box_value)
1886
1887                        self.__vault.account[x.account].count -= 1
1888                    del self.__vault.account[x.account].log[x.ref]
1889
1890                case Action.SUBTRACT:
1891                    assert x.ref in self.__vault.account[x.account].box
1892                    assert x.value is not None
1893                    if dry:
1894                        continue
1895                    self.__vault.account[x.account].box[x.ref].rest += x.value
1896                    self.__vault.account[x.account].balance += x.value
1897                    sub_positive_log_negative = x.value
1898
1899                case Action.ADD_FILE:
1900                    assert x.ref in self.__vault.account[x.account].log
1901                    assert x.file is not None
1902                    assert dry or x.file in self.__vault.account[x.account].log[x.ref].file
1903                    if dry:
1904                        continue
1905                    del self.__vault.account[x.account].log[x.ref].file[x.file]
1906
1907                case Action.REMOVE_FILE:
1908                    assert x.ref in self.__vault.account[x.account].log
1909                    assert x.file is not None
1910                    assert x.value is not None
1911                    if dry:
1912                        continue
1913                    self.__vault.account[x.account].log[x.ref].file[x.file] = x.value
1914
1915                case Action.BOX_TRANSFER:
1916                    assert x.ref in self.__vault.account[x.account].box
1917                    assert x.value is not None
1918                    if dry:
1919                        continue
1920                    self.__vault.account[x.account].box[x.ref].rest -= x.value
1921
1922                case Action.EXCHANGE:
1923                    assert x.account in self.__vault.exchange
1924                    assert x.ref in self.__vault.exchange[x.account]
1925                    if dry:
1926                        continue
1927                    del self.__vault.exchange[x.account][x.ref]
1928
1929                case Action.REPORT:
1930                    assert x.ref in self.__vault.report
1931                    if dry:
1932                        continue
1933                    del self.__vault.report[x.ref]
1934
1935                case Action.ZAKAT:
1936                    assert x.ref in self.__vault.account[x.account].box
1937                    assert x.key is not None
1938                    assert hasattr(self.__vault.account[x.account].box[x.ref].zakat, x.key)
1939                    if dry:
1940                        continue
1941                    match x.math:
1942                        case MathOperation.ADDITION:
1943                            setattr(
1944                                self.__vault.account[x.account].box[x.ref].zakat,
1945                                x.key,
1946                                getattr(self.__vault.account[x.account].box[x.ref].zakat, x.key) - x.value,
1947                            )
1948                        case MathOperation.EQUAL:
1949                            setattr(
1950                                self.__vault.account[x.account].box[x.ref].zakat,
1951                                x.key,
1952                                x.value,
1953                            )
1954                        case MathOperation.SUBTRACTION:
1955                            setattr(
1956                                self.__vault.account[x.account].box[x.ref],
1957                                x.key,
1958                                getattr(self.__vault.account[x.account].box[x.ref], x.key) + x.value,
1959                            )
1960
1961        if not dry:
1962            del self.__vault.history[ref]
1963        return True
1964
1965    def vault(self) -> dict:
1966        """
1967        Returns a copy of the internal vault dictionary.
1968
1969        This method is used to retrieve the current state of the ZakatTracker object.
1970        It provides a snapshot of the internal data structure, allowing for further
1971        processing or analysis.
1972
1973        Parameters:
1974        None
1975
1976        Returns:
1977        - dict: A copy of the internal vault dictionary.
1978        """
1979        return dataclasses.asdict(self.__vault)
1980
1981    @staticmethod
1982    def stats_init() -> FileStats:
1983        """
1984        Initialize and return the initial file statistics.
1985
1986        Returns:
1987        - FileStats: A :class:`FileStats` instance with initial values
1988            of 0 bytes for both RAM and database.
1989        """
1990        return FileStats(
1991            database=SizeInfo(0, '0'),
1992            ram=SizeInfo(0, '0'),
1993        )
1994
1995    def stats(self, ignore_ram: bool = True) -> FileStats:
1996        """
1997        Calculates and returns statistics about the object's data storage.
1998
1999        This method determines the size of the database file on disk and the
2000        size of the data currently held in RAM (likely within a dictionary).
2001        Both sizes are reported in bytes and in a human-readable format
2002        (e.g., KB, MB).
2003
2004        Parameters:
2005        - ignore_ram (bool, optional): Whether to ignore the RAM size. Default is True
2006
2007        Returns:
2008        - FileStats: A dataclass containing the following statistics:
2009
2010            * 'database': A tuple with two elements:
2011                - The database file size in bytes (float).
2012                - The database file size in human-readable format (str).
2013            * 'ram': A tuple with two elements:
2014                - The RAM usage (dictionary size) in bytes (float).
2015                - The RAM usage in human-readable format (str).
2016
2017        Example:
2018        ```bash
2019        >>> x = ZakatTracker()
2020        >>> stats = x.stats()
2021        >>> print(stats.database)
2022        SizeInfo(bytes=256000, human_readable='250.0 KB')
2023        >>> print(stats.ram)
2024        SizeInfo(bytes=12345, human_readable='12.1 KB')
2025        ```
2026        """
2027        ram_size = 0.0 if ignore_ram else self.get_dict_size(self.vault())
2028        file_size = os.path.getsize(self.path())
2029        return FileStats(
2030            database=SizeInfo(file_size, self.human_readable_size(file_size)),
2031            ram=SizeInfo(ram_size, self.human_readable_size(ram_size)),
2032        )
2033
2034    def files(self) -> list[FileInfo]:
2035        """
2036        Retrieves information about files associated with this class.
2037
2038        This class method provides a standardized way to gather details about
2039        files used by the class for storage, snapshots, and CSV imports.
2040
2041        Parameters:
2042        None
2043
2044        Returns:
2045        - list[FileInfo]: A list of dataclass, each containing information
2046            about a specific file:
2047
2048            * type (str): The type of file ('database', 'snapshot', 'import_csv').
2049            * path (str): The full file path.
2050            * exists (bool): Whether the file exists on the filesystem.
2051            * size (int): The file size in bytes (0 if the file doesn't exist).
2052            * human_readable_size (str): A human-friendly representation of the file size (e.g., '10 KB', '2.5 MB').
2053        """
2054        result = []
2055        for file_type, path in {
2056            'database': self.path(),
2057            'snapshot': self.snapshot_cache_path(),
2058            'import_csv': self.import_csv_cache_path(),
2059        }.items():
2060            exists = os.path.exists(path)
2061            size = os.path.getsize(path) if exists else 0
2062            human_readable_size = self.human_readable_size(size) if exists else '0'
2063            result.append(FileInfo(
2064                type=file_type,
2065                path=path,
2066                exists=exists,
2067                size=size,
2068                human_readable_size=human_readable_size,
2069            ))
2070        return result
2071
2072    def account_exists(self, account: AccountID) -> bool:
2073        """
2074        Check if the given account exists in the vault.
2075
2076        Parameters:
2077        - account (AccountID): The account reference to check.
2078
2079        Returns:
2080        - bool: True if the account exists, False otherwise.
2081        """
2082        account = AccountID(account)
2083        return account in self.__vault.account
2084
2085    def box_size(self, account: AccountID) -> int:
2086        """
2087        Calculate the size of the box for a specific account.
2088
2089        Parameters:
2090        - account (AccountID): The account reference for which the box size needs to be calculated.
2091
2092        Returns:
2093        - int: The size of the box for the given account. If the account does not exist, -1 is returned.
2094        """
2095        if self.account_exists(account):
2096            return len(self.__vault.account[account].box)
2097        return -1
2098
2099    def log_size(self, account: AccountID) -> int:
2100        """
2101        Get the size of the log for a specific account.
2102
2103        Parameters:
2104        - account (AccountID): The account reference for which the log size needs to be calculated.
2105
2106        Returns:
2107        - int: The size of the log for the given account. If the account does not exist, -1 is returned.
2108        """
2109        if self.account_exists(account):
2110            return len(self.__vault.account[account].log)
2111        return -1
2112
2113    @staticmethod
2114    def hash_data(data: bytes, algorithm: str = 'blake2b') -> str:
2115        """
2116        Calculates the hash of given byte data using the specified algorithm.
2117
2118        Parameters:
2119        - data (bytes): The byte data to hash.
2120        - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'.
2121
2122        Returns:
2123        - str: The hexadecimal representation of the data's hash.
2124        """
2125        hash_obj = hashlib.new(algorithm)
2126        hash_obj.update(data)
2127        return hash_obj.hexdigest()
2128
2129    @staticmethod
2130    def hash_file(file_path: str, algorithm: str = 'blake2b') -> str:
2131        """
2132        Calculates the hash of a file using the specified algorithm.
2133
2134        Parameters:
2135        - file_path (str): The path to the file.
2136        - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'.
2137
2138        Returns:
2139        - str: The hexadecimal representation of the file's hash.
2140        """
2141        hash_obj = hashlib.new(algorithm)  # Create the hash object
2142        with open(file_path, 'rb') as file:  # Open file in binary mode for reading
2143            for chunk in iter(lambda: file.read(4096), b''):  # Read file in chunks
2144                hash_obj.update(chunk)
2145        return hash_obj.hexdigest()  # Return the hash as a hexadecimal string
2146
2147    def snapshot_cache_path(self):
2148        """
2149        Generate the path for the cache file used to store snapshots.
2150
2151        The cache file is a json file that stores the timestamps of the snapshots.
2152        The file name is derived from the main database file name by replacing the '.json' extension with '.snapshots.json'.
2153
2154        Parameters:
2155        None
2156
2157        Returns:
2158        - str: The path to the cache file.
2159        """
2160        path = str(self.path())
2161        ext = self.ext()
2162        ext_len = len(ext)
2163        if path.endswith(f'.{ext}'):
2164            path = path[:-ext_len - 1]
2165        _, filename = os.path.split(path + f'.snapshots.{ext}')
2166        return self.base_path(filename)
2167
2168    def snapshot(self) -> bool:
2169        """
2170        This function creates a snapshot of the current database state.
2171
2172        The function calculates the hash of the current database file and checks if a snapshot with the same hash already exists.
2173        If a snapshot with the same hash exists, the function returns True without creating a new snapshot.
2174        If a snapshot with the same hash does not exist, the function creates a new snapshot by saving the current database state
2175        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.
2176
2177        Parameters:
2178        None
2179
2180        Returns:
2181        - bool: True if a snapshot with the same hash already exists or if the snapshot is successfully created. False if the snapshot creation fails.
2182        """
2183        current_hash = self.hash_file(self.path())
2184        cache: dict[str, int] = {}  # hash: time_ns
2185        try:
2186            with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream:
2187                cache = json.load(stream, cls=JSONDecoder)
2188        except:
2189            pass
2190        if current_hash in cache:
2191            return True
2192        ref = time.time_ns()
2193        cache[current_hash] = ref
2194        if not self.save(self.base_path('snapshots', f'{ref}.{self.ext()}')):
2195            return False
2196        with open(self.snapshot_cache_path(), 'w', encoding='utf-8') as stream:
2197            stream.write(json.dumps(cache, cls=JSONEncoder))
2198        return True
2199
2200    def snapshots(self, hide_missing: bool = True, verified_hash_only: bool = False) \
2201            -> dict[int, tuple[str, str, bool]]:
2202        """
2203        Retrieve a dictionary of snapshots, with their respective hashes, paths, and existence status.
2204
2205        Parameters:
2206        - hide_missing (bool, optional): If True, only include snapshots that exist in the dictionary. Default is True.
2207        - verified_hash_only (bool, optional): If True, only include snapshots with a valid hash. Default is False.
2208
2209        Returns:
2210        - dict[int, tuple[str, str, bool]]: A dictionary where the keys are the timestamps of the snapshots,
2211        and the values are tuples containing the snapshot's hash, path, and existence status.
2212        """
2213        cache: dict[str, int] = {}  # hash: time_ns
2214        try:
2215            with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream:
2216                cache = json.load(stream, cls=JSONDecoder)
2217        except:
2218            pass
2219        if not cache:
2220            return {}
2221        result: dict[int, tuple[str, str, bool]] = {}  # time_ns: (hash, path, exists)
2222        for hash_file, ref in cache.items():
2223            path = self.base_path('snapshots', f'{ref}.{self.ext()}')
2224            exists = os.path.exists(path)
2225            valid_hash = self.hash_file(path) == hash_file if verified_hash_only else True
2226            if (verified_hash_only and not valid_hash) or (verified_hash_only and not exists):
2227                continue
2228            if exists or not hide_missing:
2229                result[ref] = (hash_file, path, exists)
2230        return result
2231
2232    def ref_exists(self, account: AccountID, ref_type: str, ref: Timestamp) -> bool:
2233        """
2234        Check if a specific reference (transaction) exists in the vault for a given account and reference type.
2235
2236        Parameters:
2237        - account (AccountID): The account reference for which to check the existence of the reference.
2238        - ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
2239        - ref (Timestamp): The reference (transaction) number to check for existence.
2240
2241        Returns:
2242        - bool: True if the reference exists for the given account and reference type, False otherwise.
2243        """
2244        account = AccountID(account)
2245        if account in self.__vault.account:
2246            return ref in getattr(self.__vault.account[account], ref_type)
2247        return False
2248
2249    def box_exists(self, account: AccountID, ref: Timestamp) -> bool:
2250        """
2251        Check if a specific box (transaction) exists in the vault for a given account and reference.
2252
2253        Parameters:
2254        - account (AccountID): The account reference for which to check the existence of the box.
2255        - ref (Timestamp): The reference (transaction) number to check for existence.
2256
2257        Returns:
2258        - bool: True if the box exists for the given account and reference, False otherwise.
2259        """
2260        return self.ref_exists(account, 'box', ref)
2261
2262    def track(self, unscaled_value: float | int | decimal.Decimal = 0, desc: str = '', account: AccountID = AccountID('1'),
2263              created_time_ns: Optional[Timestamp] = None,
2264              debug: bool = False) -> Optional[Timestamp]:
2265        """
2266        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.
2267
2268        Parameters:
2269        - unscaled_value (float | int | decimal.Decimal, optional): The value of the transaction. Default is 0.
2270        - desc (str, optional): The description of the transaction. Default is an empty string.
2271        - account (AccountID, optional): The account reference for which the transaction is being tracked. Default is '1'.
2272        - 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.
2273        - debug (bool, optional): Whether to print debug information. Default is False.
2274
2275        Returns:
2276        - Optional[Timestamp]: The timestamp of the transaction in nanoseconds since epoch(1AD).
2277
2278        Raises:
2279        - ValueError: The created_time_ns should be greater than zero.
2280        - ValueError: The log transaction happened again in the same nanosecond time.
2281        - ValueError: The box transaction happened again in the same nanosecond time.
2282        """
2283        return self.__track(
2284            unscaled_value=unscaled_value,
2285            desc=desc,
2286            account=account,
2287            logging=True,
2288            created_time_ns=created_time_ns,
2289            debug=debug,
2290        )
2291
2292    def __track(self, unscaled_value: float | int | decimal.Decimal = 0, desc: str = '', account: AccountID = AccountID('1'),
2293              logging: bool = True,
2294              created_time_ns: Optional[Timestamp] = None,
2295              debug: bool = False) -> Optional[Timestamp]:
2296        """
2297        Internal function to track a transaction.
2298
2299        This function handles the core logic for tracking a transaction, including account creation, logging, and box creation.
2300
2301        Parameters:
2302        - unscaled_value (float | int | decimal.Decimal, optional): The monetary value of the transaction. Defaults to 0.
2303        - desc (str, optional): A description of the transaction. Defaults to an empty string.
2304        - account (AccountID, optional): The reference of the account to track the transaction for. Defaults to '1'.
2305        - logging (bool, optional): Enables transaction logging. Defaults to True.
2306        - 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.
2307        - debug (bool, optional): Enables debug printing. Defaults to False.
2308
2309        Returns:
2310        - Optional[Timestamp]: The timestamp of the transaction in nanoseconds since the epoch.
2311
2312        Raises:
2313        - ValueError: If `created_time_ns` is not greater than zero.
2314        - ValueError: If a box transaction already exists for the given `account` and `created_time_ns`.
2315        """
2316        if debug:
2317            print('track', f'unscaled_value={unscaled_value}, debug={debug}')
2318        account = AccountID(account)
2319        if created_time_ns is None:
2320            created_time_ns = Time.time()
2321        if created_time_ns <= 0:
2322            raise ValueError('The created should be greater than zero.')
2323        no_lock = self.nolock()
2324        lock = self.__lock()
2325        if not self.account_exists(account):
2326            if debug:
2327                print(f'account {account} created')
2328            self.__vault.account[account] = Account(
2329                balance=0,
2330                created=created_time_ns,
2331            )
2332            self.__step(Action.CREATE, account)
2333        if unscaled_value == 0:
2334            if no_lock:
2335                assert lock is not None
2336                self.free(lock)
2337            return None
2338        value = self.scale(unscaled_value)
2339        if logging:
2340            self.__log(value=value, desc=desc, account=account, created_time_ns=created_time_ns, ref=None, debug=debug)
2341        if debug:
2342            print('create-box', created_time_ns)
2343        if self.box_exists(account, created_time_ns):
2344            raise ValueError(f'The box transaction happened again in the same nanosecond time({created_time_ns}).')
2345        if debug:
2346            print('created-box', created_time_ns)
2347        self.__vault.account[account].box[created_time_ns] = Box(
2348            capital=value,
2349            rest=value,
2350            zakat=BoxZakat(0, 0, 0),
2351        )
2352        self.__step(Action.TRACK, account, ref=created_time_ns, value=value)
2353        if no_lock:
2354            assert lock is not None
2355            self.free(lock)
2356        return created_time_ns
2357
2358    def log_exists(self, account: AccountID, ref: Timestamp) -> bool:
2359        """
2360        Checks if a specific transaction log entry exists for a given account.
2361
2362        Parameters:
2363        - account (AccountID): The account reference associated with the transaction log.
2364        - ref (Timestamp): The reference to the transaction log entry.
2365
2366        Returns:
2367        - bool: True if the transaction log entry exists, False otherwise.
2368        """
2369        return self.ref_exists(account, 'log', ref)
2370
2371    def __log(self, value: int, desc: str = '', account: AccountID = AccountID('1'),
2372             created_time_ns: Optional[Timestamp] = None,
2373             ref: Optional[Timestamp] = None,
2374             debug: bool = False) -> Timestamp:
2375        """
2376        Log a transaction into the account's log by updates the account's balance, count, and log with the transaction details.
2377        It also creates a step in the history of the transaction.
2378
2379        Parameters:
2380        - value (int): The value of the transaction.
2381        - desc (str, optional): The description of the transaction.
2382        - account (AccountID, optional): The account reference to log the transaction into. Default is '1'.
2383        - created_time_ns (int, optional): The timestamp of the transaction in nanoseconds since epoch(1AD).
2384                                           If not provided, it will be generated.
2385        - ref (Timestamp, optional): The reference of the object.
2386        - debug (bool, optional): Whether to print debug information. Default is False.
2387
2388        Returns:
2389        - Timestamp: The timestamp of the logged transaction.
2390
2391        Raises:
2392        - ValueError: The created_time_ns should be greater than zero.
2393        - ValueError: The log transaction happened again in the same nanosecond time.
2394        """
2395        if debug:
2396            print('_log', f'debug={debug}')
2397        account = AccountID(account)
2398        if created_time_ns is None:
2399            created_time_ns = Time.time()
2400        if created_time_ns <= 0:
2401            raise ValueError('The created should be greater than zero.')
2402        try:
2403            self.__vault.account[account].balance += value
2404        except TypeError:
2405            self.__vault.account[account].balance += decimal.Decimal(value)
2406        self.__vault.account[account].count += 1
2407        if debug:
2408            print('create-log', created_time_ns)
2409        if self.log_exists(account, created_time_ns):
2410            raise ValueError(f'The log transaction happened again in the same nanosecond time({created_time_ns}).')
2411        if debug:
2412            print('created-log', created_time_ns)
2413        self.__vault.account[account].log[created_time_ns] = Log(
2414            value=value,
2415            desc=desc,
2416            ref=ref,
2417            file={},
2418        )
2419        self.__step(Action.LOG, account, ref=created_time_ns, value=value)
2420        return created_time_ns
2421
2422    def exchange(self, account: AccountID, created_time_ns: Optional[Timestamp] = None,
2423                 rate: Optional[float] = None, description: Optional[str] = None, debug: bool = False) -> Exchange:
2424        """
2425        This method is used to record or retrieve exchange rates for a specific account.
2426
2427        Parameters:
2428        - account (AccountID): The account reference for which the exchange rate is being recorded or retrieved.
2429        - created_time_ns (Timestamp, optional): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
2430        - rate (float, optional): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
2431        - description (str, optional): A description of the exchange rate.
2432        - debug (bool, optional): Whether to print debug information. Default is False.
2433
2434        Returns:
2435        - Exchange: A dictionary containing the latest exchange rate and its description. If no exchange rate is found,
2436        it returns a dictionary with default values for the rate and description.
2437
2438        Raises:
2439        - ValueError: The created should be greater than zero.
2440        """
2441        if debug:
2442            print('exchange', f'debug={debug}')
2443        account = AccountID(account)
2444        if created_time_ns is None:
2445            created_time_ns = Time.time()
2446        if created_time_ns <= 0:
2447            raise ValueError('The created should be greater than zero.')
2448        if rate is not None:
2449            if rate <= 0:
2450                return Exchange()
2451            if account not in self.__vault.exchange:
2452                self.__vault.exchange[account] = {}
2453            if len(self.__vault.exchange[account]) == 0 and rate <= 1:
2454                return Exchange(time=created_time_ns, rate=1)
2455            no_lock = self.nolock()
2456            lock = self.__lock()
2457            self.__vault.exchange[account][created_time_ns] = Exchange(rate=rate, description=description)
2458            self.__step(Action.EXCHANGE, account, ref=created_time_ns, value=rate)
2459            if no_lock:
2460                assert lock is not None
2461                self.free(lock)
2462            if debug:
2463                print('exchange-created-1',
2464                      f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}')
2465
2466        if account in self.__vault.exchange:
2467            valid_rates = [(ts, r) for ts, r in self.__vault.exchange[account].items() if ts <= created_time_ns]
2468            if valid_rates:
2469                latest_rate = max(valid_rates, key=lambda x: x[0])
2470                if debug:
2471                    print('exchange-read-1',
2472                          f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}',
2473                          'latest_rate', latest_rate)
2474                result = latest_rate[1]
2475                result.time = latest_rate[0]
2476                return result  # إرجاع قاموس يحتوي على المعدل والوصف
2477        if debug:
2478            print('exchange-read-0', f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}')
2479        return Exchange(time=created_time_ns, rate=1, description=None)  # إرجاع القيمة الافتراضية مع وصف فارغ
2480
2481    @staticmethod
2482    def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
2483        """
2484        This function calculates the exchanged amount of a currency.
2485
2486        Parameters:
2487        - x (float): The original amount of the currency.
2488        - x_rate (float): The exchange rate of the original currency.
2489        - y_rate (float): The exchange rate of the target currency.
2490
2491        Returns:
2492        - float: The exchanged amount of the target currency.
2493        """
2494        return (x * x_rate) / y_rate
2495
2496    def exchanges(self) -> dict[AccountID, dict[Timestamp, Exchange]]:
2497        """
2498        Retrieve the recorded exchange rates for all accounts.
2499
2500        Parameters:
2501        None
2502
2503        Returns:
2504        - dict[AccountID, dict[Timestamp, Exchange]]: A dictionary containing all recorded exchange rates.
2505        The keys are account references or numbers, and the values are dictionaries containing the exchange rates.
2506        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
2507        """
2508        return self.__vault.exchange.copy()
2509
2510    def accounts(self) -> dict[AccountID, AccountDetails]:
2511        """
2512        Returns a dictionary containing account references as keys and their respective account details as values.
2513
2514        Parameters:
2515        None
2516
2517        Returns:
2518        - dict[AccountID, AccountDetails]: A dictionary where keys are account references and values are their respective details.
2519        """
2520        return {
2521            account_id: AccountDetails(
2522                account_id=account_id,
2523                account_name=self.__vault.account[account_id].name,
2524                balance=self.__vault.account[account_id].balance,
2525            )
2526            for account_id in self.__vault.account
2527        }
2528
2529    def boxes(self, account: AccountID) -> dict[Timestamp, Box]:
2530        """
2531        Retrieve the boxes (transactions) associated with a specific account.
2532
2533        Parameters:
2534        - account (AccountID): The account reference for which to retrieve the boxes.
2535
2536        Returns:
2537        - dict[Timestamp, Box]: A dictionary containing the boxes associated with the given account.
2538        If the account does not exist, an empty dictionary is returned.
2539        """
2540        if self.account_exists(account):
2541            return self.__vault.account[account].box
2542        return {}
2543
2544    def logs(self, account: AccountID) -> dict[Timestamp, Log]:
2545        """
2546        Retrieve the logs (transactions) associated with a specific account.
2547
2548        Parameters:
2549        - account (AccountID): The account reference for which to retrieve the logs.
2550
2551        Returns:
2552        - dict[Timestamp, Log]: A dictionary containing the logs associated with the given account.
2553        If the account does not exist, an empty dictionary is returned.
2554        """
2555        if self.account_exists(account):
2556            return self.__vault.account[account].log
2557        return {}
2558
2559    def timeline(self, weekday: WeekDay = WeekDay.FRIDAY, debug: bool = False) -> Timeline:
2560        """
2561        Aggregates transaction logs into a structured timeline.
2562
2563        This method retrieves transaction logs from all accounts and organizes them
2564        into daily, weekly, monthly, and yearly summaries. Each level of the
2565        timeline includes a `TimeSummary` object with the total positive, negative,
2566        and overall values for that period. The daily level also includes a list
2567        of individual `Transaction` records.
2568
2569        Parameters:
2570        - weekday (WeekDay, optional): The day of the week to use as the anchor
2571                for weekly summaries. Defaults to WeekDay.FRIDAY.
2572        - debug (bool, optional): If True, prints intermediate debug information
2573                during processing. Defaults to False.
2574
2575        Returns:
2576        - Timeline: An object containing the aggregated transaction data, organized
2577                into daily, weekly, monthly, and yearly summaries. The 'daily'
2578                attribute is a dictionary where keys are dates (YYYY-MM-DD) and
2579                values are `DailyRecords` objects. The 'weekly' attribute is a
2580                dictionary where keys are the starting datetime of the week and
2581                values are `TimeSummary` objects. The 'monthly' attribute is a
2582                dictionary where keys are year-month strings (YYYY-MM) and values
2583                are `TimeSummary` objects. The 'yearly' attribute is a dictionary
2584                where keys are years (YYYY) and values are `TimeSummary` objects.
2585
2586        Example:
2587        ```bash
2588        >>> from zakat import tracker
2589        >>> ledger = tracker(':memory:')
2590        >>> account1_id = ledger.create_account('account1')
2591        >>> account2_id = ledger.create_account('account2')
2592        >>> ledger.subtract(51, 'desc', account1_id)
2593        >>> ref = ledger.track(100, 'desc', account2_id)
2594        >>> ledger.add_file(account2_id, ref, 'file_0')
2595        >>> ledger.add_file(account2_id, ref, 'file_1')
2596        >>> ledger.add_file(account2_id, ref, 'file_2')
2597        >>> ledger.timeline()
2598        Timeline(
2599            daily={
2600                "2025-04-06": DailyRecords(
2601                    positive=10000,
2602                    negative=5100,
2603                    total=4900,
2604                    rows=[
2605                        Transaction(
2606                            account="account2",
2607                            account_id="63879638114290122752",
2608                            desc="desc2",
2609                            file={
2610                                63879638220705865728: "file_0",
2611                                63879638223391350784: "file_1",
2612                                63879638225766047744: "file_2",
2613                            },
2614                            value=10000,
2615                            time=63879638181936513024,
2616                            transfer=False,
2617                        ),
2618                        Transaction(
2619                            account="account1",
2620                            account_id="63879638104007106560",
2621                            desc="desc",
2622                            file={},
2623                            value=-5100,
2624                            time=63879638149199421440,
2625                            transfer=False,
2626                        ),
2627                    ],
2628                )
2629            },
2630            weekly={
2631                datetime.datetime(2025, 4, 2, 15, 56, 21): TimeSummary(
2632                    positive=10000, negative=0, total=10000
2633                ),
2634                datetime.datetime(2025, 4, 2, 15, 55, 49): TimeSummary(
2635                    positive=0, negative=5100, total=-5100
2636                ),
2637            },
2638            monthly={"2025-04": TimeSummary(positive=10000, negative=5100, total=4900)},
2639            yearly={2025: TimeSummary(positive=10000, negative=5100, total=4900)},
2640        )
2641        ```
2642        """
2643        logs: dict[Timestamp, list[Transaction]] = {}
2644        for account_id in self.accounts():
2645            for log_ref, log in self.logs(account_id).items():
2646                if log_ref not in logs:
2647                    logs[log_ref] = []
2648                logs[log_ref].append(Transaction(
2649                    account=self.name(account_id),
2650                    account_id=account_id,
2651                    desc=log.desc,
2652                    file=log.file,
2653                    value=log.value,
2654                    time=log_ref,
2655                    transfer=False,
2656                ))
2657        if debug:
2658            print('logs', logs)
2659        y = Timeline()
2660        for i in sorted(logs, reverse=True):
2661            dt = Time.time_to_datetime(i)
2662            daily = f'{dt.year}-{dt.month:02d}-{dt.day:02d}'
2663            weekly = dt - datetime.timedelta(days=weekday.value)
2664            monthly = f'{dt.year}-{dt.month:02d}'
2665            yearly = dt.year
2666            # daily
2667            if daily not in y.daily:
2668                y.daily[daily] = DailyRecords()
2669            transfer = len(logs[i]) > 1
2670            if debug:
2671                print('logs[i]', logs[i])
2672            for z in logs[i]:
2673                if debug:
2674                    print('z', z)
2675                # daily
2676                value = z.value
2677                if value > 0:
2678                    y.daily[daily].positive += value
2679                else:
2680                    y.daily[daily].negative += -value
2681                y.daily[daily].total += value
2682                z.transfer = transfer
2683                y.daily[daily].rows.append(z)
2684                # weekly
2685                if weekly not in y.weekly:
2686                    y.weekly[weekly] = TimeSummary()
2687                if value > 0:
2688                    y.weekly[weekly].positive += value
2689                else:
2690                    y.weekly[weekly].negative += -value
2691                y.weekly[weekly].total += value
2692                # monthly
2693                if monthly not in y.monthly:
2694                    y.monthly[monthly] = TimeSummary()
2695                if value > 0:
2696                    y.monthly[monthly].positive += value
2697                else:
2698                    y.monthly[monthly].negative += -value
2699                y.monthly[monthly].total += value
2700                # yearly
2701                if yearly not in y.yearly:
2702                    y.yearly[yearly] = TimeSummary()
2703                if value > 0:
2704                    y.yearly[yearly].positive += value
2705                else:
2706                    y.yearly[yearly].negative += -value
2707                y.yearly[yearly].total += value
2708        if debug:
2709            print('y', y)
2710        return y
2711
2712    def add_file(self, account: AccountID, ref: Timestamp, path: str) -> Timestamp:
2713        """
2714        Adds a file reference to a specific transaction log entry in the vault.
2715
2716        Parameters:
2717        - account (AccountID): The account reference associated with the transaction log.
2718        - ref (Timestamp): The reference to the transaction log entry.
2719        - path (str): The path of the file to be added.
2720
2721        Returns:
2722        - Timestamp: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
2723        """
2724        if self.account_exists(account):
2725            if ref in self.__vault.account[account].log:
2726                no_lock = self.nolock()
2727                lock = self.__lock()
2728                file_ref = Time.time()
2729                self.__vault.account[account].log[ref].file[file_ref] = path
2730                self.__step(Action.ADD_FILE, account, ref=ref, file=file_ref)
2731                if no_lock:
2732                    assert lock is not None
2733                    self.free(lock)
2734                return file_ref
2735        return Timestamp(0)
2736
2737    def remove_file(self, account: AccountID, ref: Timestamp, file_ref: Timestamp) -> bool:
2738        """
2739        Removes a file reference from a specific transaction log entry in the vault.
2740
2741        Parameters:
2742        - account (AccountID): The account reference associated with the transaction log.
2743        - ref (Timestamp): The reference to the transaction log entry.
2744        - file_ref (Timestamp): The reference of the file to be removed.
2745
2746        Returns:
2747        - bool: True if the file reference is successfully removed, False otherwise.
2748        """
2749        if self.account_exists(account):
2750            if ref in self.__vault.account[account].log:
2751                if file_ref in self.__vault.account[account].log[ref].file:
2752                    no_lock = self.nolock()
2753                    lock = self.__lock()
2754                    x = self.__vault.account[account].log[ref].file[file_ref]
2755                    del self.__vault.account[account].log[ref].file[file_ref]
2756                    self.__step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x)
2757                    if no_lock:
2758                        assert lock is not None
2759                        self.free(lock)
2760                    return True
2761        return False
2762
2763    def balance(self, account: AccountID = AccountID('1'), cached: bool = True) -> int:
2764        """
2765        Calculate and return the balance of a specific account.
2766
2767        Parameters:
2768        - account (AccountID, optional): The account reference. Default is '1'.
2769        - cached (bool, optional): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
2770
2771        Returns:
2772        - int: The balance of the account.
2773
2774        Notes:
2775        - If cached is True, the function returns the cached balance.
2776        - If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
2777        """
2778        account = AccountID(account)
2779        if cached:
2780            return self.__vault.account[account].balance
2781        x = 0
2782        return [x := x + y.rest for y in self.__vault.account[account].box.values()][-1]
2783
2784    def hide(self, account: AccountID, status: Optional[bool] = None) -> bool:
2785        """
2786        Check or set the hide status of a specific account.
2787
2788        Parameters:
2789        - account (AccountID): The account reference.
2790        - status (bool, optional): The new hide status. If not provided, the function will return the current status.
2791
2792        Returns:
2793        - bool: The current or updated hide status of the account.
2794
2795        Raises:
2796        None
2797
2798        Example:
2799        ```bash
2800        >>> tracker = ZakatTracker()
2801        >>> ref = tracker.track(51, 'desc', 'account1')
2802        >>> tracker.hide('account1')  # Set the hide status of 'account1' to True
2803        False
2804        >>> tracker.hide('account1', True)  # Set the hide status of 'account1' to True
2805        True
2806        >>> tracker.hide('account1')  # Get the hide status of 'account1' by default
2807        True
2808        >>> tracker.hide('account1', False)
2809        False
2810        ```
2811        """
2812        if self.account_exists(account):
2813            if status is None:
2814                return self.__vault.account[account].hide
2815            self.__vault.account[account].hide = status
2816            return status
2817        return False
2818
2819    def account(self, name: str, exact: bool = True) -> Optional[AccountDetails]:
2820        """
2821        Retrieves an AccountDetails object for the first account matching the given name.
2822
2823        This method searches for accounts with names that contain the provided 'name'
2824        (case-insensitive substring matching). If a match is found, it returns an
2825        AccountDetails object containing the account's ID, name and balance. If no matching
2826        account is found, it returns None.
2827
2828        Parameters:
2829        - name: The name (or partial name) of the account to retrieve.
2830        - exact: If True, performs a case-insensitive exact match.
2831                 If False, performs a case-insensitive substring search.
2832                 Defaults to True.
2833
2834        Returns:
2835        - AccountDetails: An AccountDetails object representing the found account, or None if no
2836            matching account exists.
2837        """
2838        for account_name, account_id in self.names(name).items():
2839            if not exact or account_name.lower() == name.lower():
2840                return AccountDetails(
2841                    account_id=account_id,
2842                    account_name=account_name,
2843                    balance=self.__vault.account[account_id].balance,
2844                )
2845        return None
2846
2847    def create_account(self, name: str) -> AccountID:
2848        """
2849        Creates a new account with the given name and returns its unique ID.
2850
2851        This method:
2852        1. Checks if an account with the same name (case-insensitive) already exists.
2853        2. Generates a unique `AccountID` based on the current time.
2854        3. Tracks the account creation internally.
2855        4. Sets the account's name.
2856        5. Verifies that the name was set correctly.
2857    
2858        Parameters:
2859        - name: The name of the new account.
2860    
2861        Returns:
2862        - AccountID: The unique `AccountID` of the newly created account.
2863    
2864        Raises:
2865        - AssertionError: Empty account name is forbidden.
2866        - AssertionError: Account name in number is forbidden.
2867        - AssertionError: If an account with the same name already exists (case-insensitive).
2868        - AssertionError: If the provided name does not match the name set for the account.
2869        """
2870        assert name.strip(), 'empty account name is forbidden'
2871        assert not name.isdigit() and not name.isdecimal() and not name.isnumeric() and not is_number(name), f'Account name({name}) in number is forbidden'
2872        account_ref = self.account(name, exact=True)
2873        # check if account not exists
2874        assert account_ref is None, f'account name({name}) already used'
2875        # create new account
2876        account_id = AccountID(Time.time())
2877        self.__track(0, '', account_id)
2878        new_name = self.name(
2879            account=account_id,
2880            new_name=name,
2881        )
2882        assert name == new_name
2883        return account_id
2884
2885    def names(self, keyword: str = '') -> dict[str, AccountID]:
2886        """
2887        Retrieves a dictionary of account IDs and names, optionally filtered by a keyword.
2888
2889        Parameters:
2890        - keyword: An optional string to filter account names. If provided, only accounts whose
2891            names contain the keyword (case-insensitive) will be included in the result.
2892            Defaults to an empty string, which returns all accounts.
2893
2894        Returns:
2895        - A dictionary where keys are account names and values are AccountIDs. The dictionary
2896            contains only accounts that match the provided keyword (if any).
2897        """
2898        return {
2899            account.name: account_id
2900            for account_id, account in self.__vault.account.items()
2901            if keyword.lower() in account.name.lower()
2902        }
2903
2904    def name(self, account: AccountID, new_name: Optional[str] = None) -> str:
2905        """
2906        Retrieves or sets the name of an account.
2907
2908        Parameters:
2909        - account: The AccountID of the account.
2910        - new_name: The new name to set for the account. If None, the current name is retrieved.
2911
2912        Returns:
2913        - The current name of the account if `new_name` is None, or the `new_name` if it is set.
2914
2915        Note: Returns an empty string if the account does not exist.
2916        """
2917        if self.account_exists(account):
2918            if new_name is None:
2919                return self.__vault.account[account].name
2920            assert new_name != ''
2921            no_lock = self.nolock()
2922            lock = self.__lock()
2923            self.__step(Action.NAME, account, value=self.__vault.account[account].name)
2924            self.__vault.account[account].name = new_name
2925            if no_lock:
2926                    assert lock is not None
2927                    self.free(lock)
2928            return new_name
2929        return ''
2930
2931    def zakatable(self, account: AccountID, status: Optional[bool] = None) -> bool:
2932        """
2933        Check or set the zakatable status of a specific account.
2934
2935        Parameters:
2936        - account (AccountID): The account reference.
2937        - status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
2938
2939        Returns:
2940        - bool: The current or updated zakatable status of the account.
2941
2942        Raises:
2943        None
2944
2945        Example:
2946        ```bash
2947        >>> tracker = ZakatTracker()
2948        >>> ref = tracker.track(51, 'desc', 'account1')
2949        >>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
2950        True
2951        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
2952        True
2953        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
2954        True
2955        >>> tracker.zakatable('account1', False)
2956        False
2957        ```
2958        """
2959        if self.account_exists(account):
2960            if status is None:
2961                return self.__vault.account[account].zakatable
2962            self.__vault.account[account].zakatable = status
2963            return status
2964        return False
2965
2966    def subtract(self, unscaled_value: float | int | decimal.Decimal, desc: str = '', account: AccountID = AccountID('1'),
2967            created_time_ns: Optional[Timestamp] = None,
2968            debug: bool = False) \
2969            -> SubtractReport:
2970        """
2971        Subtracts a specified value from an account's balance, if the amount to subtract is greater than the account's balance,
2972        the remaining amount will be transferred to a new transaction with a negative value.
2973
2974        Parameters:
2975        - unscaled_value (float | int | decimal.Decimal): The amount to be subtracted.
2976        - desc (str, optional): A description for the transaction. Defaults to an empty string.
2977        - account (AccountID, optional): The account reference from which the value will be subtracted. Defaults to '1'.
2978        - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD).
2979                                           If not provided, the current timestamp will be used.
2980        - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False.
2981
2982        Returns:
2983        - SubtractReport: A class containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
2984
2985        Raises:
2986        - ValueError: The unscaled_value should be greater than zero.
2987        - ValueError: The created_time_ns should be greater than zero.
2988        - ValueError: The box transaction happened again in the same nanosecond time.
2989        - ValueError: The log transaction happened again in the same nanosecond time.
2990        """
2991        if debug:
2992            print('sub', f'debug={debug}')
2993        account = AccountID(account)
2994        if unscaled_value <= 0:
2995            raise ValueError('The unscaled_value should be greater than zero.')
2996        if created_time_ns is None:
2997            created_time_ns = Time.time()
2998        if created_time_ns <= 0:
2999            raise ValueError('The created should be greater than zero.')
3000        no_lock = self.nolock()
3001        lock = self.__lock()
3002        self.__track(0, '', account)
3003        value = self.scale(unscaled_value)
3004        self.__log(value=-value, desc=desc, account=account, created_time_ns=created_time_ns, ref=None, debug=debug)
3005        ids = sorted(self.__vault.account[account].box.keys())
3006        limit = len(ids) + 1
3007        target = value
3008        if debug:
3009            print('ids', ids)
3010        ages = SubtractAges()
3011        for i in range(-1, -limit, -1):
3012            if target == 0:
3013                break
3014            j = ids[i]
3015            if debug:
3016                print('i', i, 'j', j)
3017            rest = self.__vault.account[account].box[j].rest
3018            if rest >= target:
3019                self.__vault.account[account].box[j].rest -= target
3020                self.__step(Action.SUBTRACT, account, ref=j, value=target)
3021                ages.append(SubtractAge(box_ref=j, total=target))
3022                target = 0
3023                break
3024            elif target > rest > 0:
3025                chunk = rest
3026                target -= chunk
3027                self.__vault.account[account].box[j].rest = 0
3028                self.__step(Action.SUBTRACT, account, ref=j, value=chunk)
3029                ages.append(SubtractAge(box_ref=j, total=chunk))
3030        if target > 0:
3031            self.__track(
3032                unscaled_value=self.unscale(-target),
3033                desc=desc,
3034                account=account,
3035                logging=False,
3036                created_time_ns=created_time_ns,
3037            )
3038            ages.append(SubtractAge(box_ref=created_time_ns, total=target))
3039        if no_lock:
3040            assert lock is not None
3041            self.free(lock)
3042        return SubtractReport(
3043            log_ref=created_time_ns,
3044            ages=ages,
3045        )
3046
3047    def transfer(self, unscaled_amount: float | int | decimal.Decimal, from_account: AccountID, to_account: AccountID, desc: str = '',
3048                 created_time_ns: Optional[Timestamp] = None,
3049                 debug: bool = False) -> Optional[TransferReport]:
3050        """
3051        Transfers a specified value from one account to another.
3052
3053        Parameters:
3054        - unscaled_amount (float | int | decimal.Decimal): The amount to be transferred.
3055        - from_account (AccountID): The account reference from which the value will be transferred.
3056        - to_account (AccountID): The account reference to which the value will be transferred.
3057        - desc (str, optional): A description for the transaction. Defaults to an empty string.
3058        - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, the current timestamp will be used.
3059        - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False.
3060
3061        Returns:
3062        - Optional[TransferReport]: A class of timestamps corresponding to the transactions made during the transfer.
3063
3064        Raises:
3065        - ValueError: Transfer to the same account is forbidden.
3066        - ValueError: The created_time_ns should be greater than zero.
3067        - ValueError: The box transaction happened again in the same nanosecond time.
3068        - ValueError: The log transaction happened again in the same nanosecond time.
3069        """
3070        if debug:
3071            print('transfer', f'debug={debug}')
3072        from_account = AccountID(from_account)
3073        to_account = AccountID(to_account)
3074        if from_account == to_account:
3075            raise ValueError(f'Transfer to the same account is forbidden. {to_account}')
3076        if unscaled_amount <= 0:
3077            return None
3078        if created_time_ns is None:
3079            created_time_ns = Time.time()
3080        if created_time_ns <= 0:
3081            raise ValueError('The created should be greater than zero.')
3082        no_lock = self.nolock()
3083        lock = self.__lock()
3084        subtract_report = self.subtract(unscaled_amount, desc, from_account, created_time_ns, debug=debug)
3085        source_exchange = self.exchange(from_account, created_time_ns)
3086        target_exchange = self.exchange(to_account, created_time_ns)
3087
3088        if debug:
3089            print('ages', subtract_report.ages)
3090
3091        transfer_report = TransferReport()
3092        for subtract in subtract_report.ages:
3093            times = TransferTimes()
3094            age = subtract.box_ref
3095            value = subtract.total
3096            assert source_exchange.rate is not None
3097            assert target_exchange.rate is not None
3098            target_amount = int(self.exchange_calc(value, source_exchange.rate, target_exchange.rate))
3099            if debug:
3100                print('target_amount', target_amount)
3101            # Perform the transfer
3102            if self.box_exists(to_account, age):
3103                if debug:
3104                    print('box_exists', age)
3105                capital = self.__vault.account[to_account].box[age].capital
3106                rest = self.__vault.account[to_account].box[age].rest
3107                if debug:
3108                    print(
3109                        f'Transfer(loop) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).')
3110                selected_age = age
3111                if rest + target_amount > capital:
3112                    self.__vault.account[to_account].box[age].capital += target_amount
3113                    selected_age = Time.time()
3114                self.__vault.account[to_account].box[age].rest += target_amount
3115                self.__step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount)
3116                y = self.__log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account,
3117                              created_time_ns=None, ref=None, debug=debug)
3118                times.append(TransferTime(box_ref=age, log_ref=y))
3119                continue
3120            if debug:
3121                print(
3122                    f'Transfer(func) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).')
3123            box_ref = self.__track(
3124                unscaled_value=self.unscale(int(target_amount)),
3125                desc=desc,
3126                account=to_account,
3127                logging=True,
3128                created_time_ns=age,
3129                debug=debug,
3130            )
3131            transfer_report.append(TransferRecord(
3132                box_ref=box_ref,
3133                times=times,
3134            ))
3135        if no_lock:
3136            assert lock is not None
3137            self.free(lock)
3138        return transfer_report
3139    
3140    def check(self,
3141              silver_gram_price: float,
3142              unscaled_nisab: Optional[float | int | decimal.Decimal] = None,
3143              debug: bool = False,
3144              created_time_ns: Optional[Timestamp] = None,
3145              cycle: Optional[float] = None) -> ZakatReport:
3146        """
3147        Check the eligibility for Zakat based on the given parameters.
3148
3149        Parameters:
3150        - silver_gram_price (float): The price of a gram of silver.
3151        - unscaled_nisab (float | int | decimal.Decimal, optional): The minimum amount of wealth required for Zakat.
3152                        If not provided, it will be calculated based on the silver_gram_price.
3153        - debug (bool, optional): Flag to enable debug mode.
3154        - created_time_ns (Timestamp, optional): The current timestamp. If not provided, it will be calculated using Time.time().
3155        - cycle (float, optional): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
3156
3157        Returns:
3158        - ZakatReport: A tuple containing a boolean indicating the eligibility for Zakat,
3159            a list of brief statistics, and a dictionary containing the Zakat plan.
3160        """
3161        if debug:
3162            print('check', f'debug={debug}')
3163        before_parameters = {
3164            "silver_gram_price": silver_gram_price,
3165            "unscaled_nisab": unscaled_nisab,
3166            "debug": debug,
3167            "created_time_ns": created_time_ns,
3168            "cycle": cycle,
3169        }
3170        if created_time_ns is None:
3171            created_time_ns = Time.time()
3172        if cycle is None:
3173            cycle = ZakatTracker.TimeCycle()
3174        if unscaled_nisab is None:
3175            unscaled_nisab = ZakatTracker.Nisab(silver_gram_price)
3176        nisab = self.scale(unscaled_nisab)
3177        plan: dict[AccountID, list[BoxPlan]] = {}
3178        summary = ZakatSummary()
3179        below_nisab = 0
3180        valid = False
3181        after_parameters = {
3182            "silver_gram_price": silver_gram_price,
3183            "unscaled_nisab": unscaled_nisab,
3184            "debug": debug,
3185            "created_time_ns": created_time_ns,
3186            "cycle": cycle,
3187        }
3188        if debug:
3189            print('exchanges', self.exchanges())
3190        for x in self.__vault.account:
3191            if not self.zakatable(x):
3192                continue
3193            _box = self.__vault.account[x].box
3194            _log = self.__vault.account[x].log
3195            limit = len(_box) + 1
3196            ids = sorted(self.__vault.account[x].box.keys())
3197            for i in range(-1, -limit, -1):
3198                j = ids[i]
3199                rest = float(_box[j].rest)
3200                if rest <= 0:
3201                    continue
3202                exchange = self.exchange(x, created_time_ns=Time.time())
3203                assert exchange.rate is not None
3204                rest = ZakatTracker.exchange_calc(rest, float(exchange.rate), 1)
3205                summary.num_wealth_items += 1
3206                summary.total_wealth += rest
3207                epoch = (created_time_ns - j) / cycle
3208                if debug:
3209                    print(f'Epoch: {epoch}', _box[j])
3210                if _box[j].zakat.last > 0:
3211                    epoch = (created_time_ns - _box[j].zakat.last) / cycle
3212                if debug:
3213                    print(f'Epoch: {epoch}')
3214                epoch = math.floor(epoch)
3215                if debug:
3216                    print(f'Epoch: {epoch}', type(epoch), epoch == 0, 1 - epoch, epoch)
3217                if epoch == 0:
3218                    continue
3219                if debug:
3220                    print('Epoch - PASSED')
3221                summary.num_zakatable_items += 1
3222                summary.total_zakatable_amount += rest
3223                is_nisab = rest >= nisab
3224                total = 0
3225                if is_nisab:
3226                    for _ in range(epoch):
3227                        total += ZakatTracker.ZakatCut(float(rest) - float(total))
3228                    valid = total > 0
3229                elif rest > 0:
3230                    below_nisab += rest
3231                    total = ZakatTracker.ZakatCut(float(rest))
3232                if total > 0:
3233                    if x not in plan:
3234                        plan[x] = []
3235                    summary.total_zakat_due += total
3236                    plan[x].append(BoxPlan(
3237                        below_nisab=not is_nisab,
3238                        total=total,
3239                        count=epoch,
3240                        ref=j,
3241                        box=_box[j],
3242                        log=_log[j],
3243                        exchange=exchange,
3244                    ))
3245        valid = valid or below_nisab >= nisab
3246        if debug:
3247            print(f'below_nisab({below_nisab}) >= nisab({nisab})')
3248        report = ZakatReport(
3249            created=Time.time(),
3250            valid=valid,
3251            summary=summary,
3252            plan=plan,
3253            parameters={
3254                'before': before_parameters,
3255                'after': after_parameters,
3256            },
3257        )
3258        self.__vault.cache.zakat = report if valid else None
3259        return report
3260
3261    def build_payment_parts(self, scaled_demand: int, positive_only: bool = True) -> PaymentParts:
3262        """
3263        Build payment parts for the Zakat distribution.
3264
3265        Parameters:
3266        - scaled_demand (int): The total demand for payment in local currency.
3267        - positive_only (bool, optional): If True, only consider accounts with positive balance. Default is True.
3268
3269        Returns:
3270        - PaymentParts: A dictionary containing the payment parts for each account. The dictionary has the following structure:
3271        {
3272            'account': {
3273                'account_id': {'balance': float, 'rate': float, 'part': float},
3274                ...
3275            },
3276            'exceed': bool,
3277            'demand': int,
3278            'total': float,
3279        }
3280        """
3281        total = 0.0
3282        parts = PaymentParts(
3283            account={},
3284            exceed=False,
3285            demand=int(round(scaled_demand)),
3286            total=0,
3287        )
3288        for x, y in self.accounts().items():
3289            if positive_only and y.balance <= 0:
3290                continue
3291            total += float(y.balance)
3292            exchange = self.exchange(x)
3293            parts.account[x] = AccountPaymentPart(balance=y.balance, rate=exchange.rate, part=0)
3294        parts.total = total
3295        return parts
3296
3297    @staticmethod
3298    def check_payment_parts(parts: PaymentParts, debug: bool = False) -> int:
3299        """
3300        Checks the validity of payment parts.
3301
3302        Parameters:
3303        - parts (dict[str, PaymentParts): A dictionary containing payment parts information.
3304        - debug (bool, optional): Flag to enable debug mode.
3305
3306        Returns:
3307        - int: Returns 0 if the payment parts are valid, otherwise returns the error code.
3308
3309        Error Codes:
3310        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
3311        2: 'balance', 'rate' or 'part' key is missing in parts['account'][x].
3312        3: 'part' value in parts['account'][x] is less than 0.
3313        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
3314        5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
3315        6: The sum of 'part' values in parts['account'] does not match with 'demand' value.
3316        """
3317        if debug:
3318            print('check_payment_parts', f'debug={debug}')
3319        # for i in ['demand', 'account', 'total', 'exceed']:
3320        #     if i not in parts:
3321        #         return 1
3322        exceed = parts.exceed
3323        # for j in ['balance', 'rate', 'part']:
3324        #     if j not in parts.account[x]:
3325        #         return 2
3326        for x in parts.account:
3327            if parts.account[x].part < 0:
3328                return 3
3329            if not exceed and parts.account[x].balance <= 0:
3330                return 4
3331        demand = parts.demand
3332        z = 0.0
3333        for _, y in parts.account.items():
3334            if not exceed and y.part > y.balance:
3335                return 5
3336            z += ZakatTracker.exchange_calc(y.part, y.rate, 1.0)
3337        z = round(z, 2)
3338        demand = round(demand, 2)
3339        if debug:
3340            print('check_payment_parts', f'z = {z}, demand = {demand}')
3341            print('check_payment_parts', type(z), type(demand))
3342            print('check_payment_parts', z != demand)
3343            print('check_payment_parts', str(z) != str(demand))
3344        if z != demand and str(z) != str(demand):
3345            return 6
3346        return 0
3347
3348    def zakat(self, report: ZakatReport,
3349        parts: Optional[PaymentParts] = None, debug: bool = False) -> bool:
3350        """
3351        Perform Zakat calculation based on the given report and optional parts.
3352
3353        Parameters:
3354        - report (ZakatReport): A dataclass containing the validity of the report, the report data, and the zakat plan.
3355        - parts (PaymentParts, optional): A dictionary containing the payment parts for the zakat.
3356        - debug (bool, optional): A flag indicating whether to print debug information.
3357
3358        Returns:
3359        - bool: True if the zakat calculation is successful, False otherwise.
3360
3361        Raises:
3362        - AssertionError: Bad Zakat report, call `check` first then call `zakat`.
3363        """
3364        if debug:
3365            print('zakat', f'debug={debug}')
3366        if not report.valid:
3367            return report.valid
3368        assert report.plan
3369        parts_exist = parts is not None
3370        if parts_exist:
3371            if self.check_payment_parts(parts, debug=debug) != 0:
3372                return False
3373        if debug:
3374            print('######### zakat #######')
3375            print('parts_exist', parts_exist)
3376        assert report == self.__vault.cache.zakat, "Bad Zakat report, call `check` first then call `zakat`"
3377        no_lock = self.nolock()
3378        lock = self.__lock()
3379        report_time = Time.time()
3380        self.__vault.report[report_time] = report
3381        self.__step(Action.REPORT, ref=report_time)
3382        created_time_ns = Time.time()
3383        for x in report.plan:
3384            target_exchange = self.exchange(x)
3385            if debug:
3386                print(report.plan[x])
3387                print('-------------')
3388                print(self.__vault.account[x].box)
3389            if debug:
3390                print('plan[x]', report.plan[x])
3391            for plan in report.plan[x]:
3392                j = plan.ref
3393                if debug:
3394                    print('j', j)
3395                assert j
3396                self.__step(Action.ZAKAT, account=x, ref=j, value=self.__vault.account[x].box[j].zakat.last,
3397                           key='last',
3398                           math_operation=MathOperation.EQUAL)
3399                self.__vault.account[x].box[j].zakat.last = created_time_ns
3400                assert target_exchange.rate is not None
3401                amount = ZakatTracker.exchange_calc(float(plan.total), 1, float(target_exchange.rate))
3402                self.__vault.account[x].box[j].zakat.total += amount
3403                self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='total',
3404                           math_operation=MathOperation.ADDITION)
3405                self.__vault.account[x].box[j].zakat.count += plan.count
3406                self.__step(Action.ZAKAT, account=x, ref=j, value=plan.count, key='count',
3407                           math_operation=MathOperation.ADDITION)
3408                if not parts_exist:
3409                    try:
3410                        self.__vault.account[x].box[j].rest -= amount
3411                    except TypeError:
3412                        self.__vault.account[x].box[j].rest -= decimal.Decimal(amount)
3413                    # self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest',
3414                    #            math_operation=MathOperation.SUBTRACTION)
3415                    self.__log(-float(amount), desc='zakat-زكاة', account=x, created_time_ns=None, ref=j, debug=debug)
3416        if parts_exist:
3417            for account, part in parts.account.items():
3418                if part.part == 0:
3419                    continue
3420                if debug:
3421                    print('zakat-part', account, part.rate)
3422                target_exchange = self.exchange(account)
3423                assert target_exchange.rate is not None
3424                amount = ZakatTracker.exchange_calc(part.part, part.rate, target_exchange.rate)
3425                unscaled_amount = self.unscale(int(amount))
3426                if unscaled_amount <= 0:
3427                    if debug:
3428                        print(f"The amount({unscaled_amount:.20f}) it was {amount:.20f} should be greater tha zero.")
3429                    continue
3430                self.subtract(
3431                    unscaled_value=unscaled_amount,
3432                    desc='zakat-part-دفعة-زكاة',
3433                    account=account,
3434                    debug=debug,
3435                )
3436        if no_lock:
3437            assert lock is not None
3438            self.free(lock)
3439        self.__vault.cache.zakat = None
3440        return True
3441
3442    @staticmethod
3443    def split_at_last_symbol(data: str, symbol: str) -> tuple[str, str]:
3444        """Splits a string at the last occurrence of a given symbol.
3445    
3446        Parameters:
3447        - data (str): The input string.
3448        - symbol (str): The symbol to split at.
3449    
3450        Returns:
3451        - tuple[str, str]: A tuple containing two strings, the part before the last symbol and
3452            the part after the last symbol. If the symbol is not found, returns (data, "").
3453        """
3454        last_symbol_index = data.rfind(symbol)
3455    
3456        if last_symbol_index != -1:
3457            before_symbol = data[:last_symbol_index]
3458            after_symbol = data[last_symbol_index + len(symbol):]
3459            return before_symbol, after_symbol
3460        return data, ""
3461
3462    def save(self, path: Optional[str] = None, hash_required: bool = True) -> bool:
3463        """
3464        Saves the ZakatTracker's current state to a json file.
3465
3466        This method serializes the internal data (`__vault`).
3467
3468        Parameters:
3469        - path (str, optional): File path for saving. Defaults to a predefined location.
3470        - hash_required (bool, optional): Whether to add the data integrity using a hash. Defaults to True.
3471
3472        Returns:
3473        - bool: True if the save operation is successful, False otherwise.
3474        """
3475        if path is None:
3476            path = self.path()
3477        # first save in tmp file
3478        temp = f'{path}.tmp'
3479        try:
3480            with open(temp, 'w', encoding='utf-8') as stream:
3481                data = json.dumps(self.__vault, cls=JSONEncoder)
3482                stream.write(data)
3483                if hash_required:
3484                    hashed = self.hash_data(data.encode())
3485                    stream.write(f'//{hashed}')
3486            # then move tmp file to original location
3487            shutil.move(temp, path)
3488            return True
3489        except (IOError, OSError) as e:
3490            print(f'Error saving file: {e}')
3491            if os.path.exists(temp):
3492                os.remove(temp)
3493            return False
3494
3495    @staticmethod
3496    def load_vault_from_json(json_string: str) -> Vault:
3497        """Loads a Vault dataclass from a JSON string."""
3498        data = json.loads(json_string)
3499
3500        vault = Vault()
3501
3502        # Load Accounts
3503        for account_reference, account_data in data.get("account", {}).items():
3504            account_reference = AccountID(account_reference)
3505            box_data = account_data.get('box', {})
3506            box = {
3507                Timestamp(ts): Box(
3508                    capital=box_data[str(ts)]["capital"],
3509                    rest=box_data[str(ts)]["rest"],
3510                    zakat=BoxZakat(**box_data[str(ts)]["zakat"]),
3511                )
3512                for ts in box_data
3513            }
3514
3515            log_data = account_data.get('log', {})
3516            log = {Timestamp(ts): Log(
3517                value=log_data[str(ts)]['value'],
3518                desc=log_data[str(ts)]['desc'],
3519                ref=Timestamp(log_data[str(ts)].get('ref')) if log_data[str(ts)].get('ref') is not None else None,
3520                file={Timestamp(ft): fv for ft, fv in log_data[str(ts)].get('file', {}).items()},
3521            ) for ts in log_data}
3522
3523            vault.account[account_reference] = Account(
3524                balance=account_data["balance"],
3525                created=Timestamp(account_data["created"]),
3526                name=account_data.get("name", ""),
3527                box=box,
3528                count=account_data.get("count", 0),
3529                log=log,
3530                hide=account_data.get("hide", False),
3531                zakatable=account_data.get("zakatable", True),
3532            )
3533
3534        # Load Exchanges
3535        for account_reference, exchange_data in data.get("exchange", {}).items():
3536            account_reference = AccountID(account_reference)
3537            vault.exchange[account_reference] = {}
3538            for timestamp, exchange_details in exchange_data.items():
3539                vault.exchange[account_reference][Timestamp(timestamp)] = Exchange(
3540                    rate=exchange_details.get("rate"),
3541                    description=exchange_details.get("description"),
3542                    time=Timestamp(exchange_details.get("time")) if exchange_details.get("time") is not None else None,
3543                )
3544
3545        # Load History
3546        for timestamp, history_dict in data.get("history", {}).items():
3547            vault.history[Timestamp(timestamp)] = {}
3548            for history_key, history_data in history_dict.items():
3549                vault.history[Timestamp(timestamp)][Timestamp(history_key)] = History(
3550                    action=Action(history_data["action"]),
3551                    account=AccountID(history_data["account"]) if history_data.get("account") is not None else None,
3552                    ref=Timestamp(history_data.get("ref")) if history_data.get("ref") is not None else None,
3553                    file=Timestamp(history_data.get("file")) if history_data.get("file") is not None else None,
3554                    key=history_data.get("key"),
3555                    value=history_data.get("value"),
3556                    math=MathOperation(history_data.get("math")) if history_data.get("math") is not None else None,
3557                )
3558
3559        # Load Lock
3560        vault.lock = Timestamp(data["lock"]) if data.get("lock") is not None else None
3561
3562        # Load Report
3563        for timestamp, report_data in data.get("report", {}).items():
3564            zakat_plan: dict[AccountID, list[BoxPlan]] = {}
3565            for account_reference, box_plans in report_data.get("plan", {}).items():
3566                account_reference = AccountID(account_reference)
3567                zakat_plan[account_reference] = []
3568                for box_plan_data in box_plans:
3569                    zakat_plan[account_reference].append(BoxPlan(
3570                        box=Box(
3571                            capital=box_plan_data["box"]["capital"],
3572                            rest=box_plan_data["box"]["rest"],
3573                            zakat=BoxZakat(**box_plan_data["box"]["zakat"]),
3574                        ),
3575                        log=Log(**box_plan_data["log"]),
3576                        exchange=Exchange(**box_plan_data["exchange"]),
3577                        below_nisab=box_plan_data["below_nisab"],
3578                        total=box_plan_data["total"],
3579                        count=box_plan_data["count"],
3580                        ref=Timestamp(box_plan_data["ref"]),
3581                    ))
3582
3583            vault.report[Timestamp(timestamp)] = ZakatReport(
3584                created=report_data["created"],
3585                valid=report_data["valid"],
3586                summary=ZakatSummary(**report_data["summary"]),
3587                plan=zakat_plan,
3588                parameters=report_data["parameters"],
3589            )
3590
3591        # Load Cache
3592        vault.cache = Cache()
3593        cache_data = data.get("cache", {})
3594        if "zakat" in cache_data:
3595            cache_zakat_data = cache_data.get("zakat", {})
3596            if cache_zakat_data:
3597                zakat_plan: dict[AccountID, list[BoxPlan]] = {}
3598                for account_reference, box_plans in cache_zakat_data.get("plan", {}).items():
3599                    account_reference = AccountID(account_reference)
3600                    zakat_plan[account_reference] = []
3601                    for box_plan_data in box_plans:
3602                        zakat_plan[account_reference].append(BoxPlan(
3603                            box=Box(
3604                                capital=box_plan_data["box"]["capital"],
3605                                rest=box_plan_data["box"]["rest"],
3606                                zakat=BoxZakat(**box_plan_data["box"]["zakat"]),
3607                            ),
3608                            log=Log(**box_plan_data["log"]),
3609                            exchange=Exchange(**box_plan_data["exchange"]),
3610                            below_nisab=box_plan_data["below_nisab"],
3611                            total=box_plan_data["total"],
3612                            count=box_plan_data["count"],
3613                            ref=Timestamp(box_plan_data["ref"]),
3614                        ))
3615
3616                vault.cache.zakat = ZakatReport(
3617                    created=cache_zakat_data["created"],
3618                    valid=cache_zakat_data["valid"],
3619                    summary=ZakatSummary(**cache_zakat_data["summary"]),
3620                    plan=zakat_plan,
3621                    parameters=cache_zakat_data["parameters"],
3622                )
3623
3624        return vault
3625
3626    def load(self, path: Optional[str] = None, hash_required: bool = True, debug: bool = False) -> bool:
3627        """
3628        Load the current state of the ZakatTracker object from a json file.
3629
3630        Parameters:
3631        - path (str, optional): The path where the json file is located. If not provided, it will use the default path.
3632        - hash_required (bool, optional): Whether to verify the data integrity using a hash. Defaults to True.
3633        - debug (bool, optional): Flag to enable debug mode.
3634
3635        Returns:
3636        - bool: True if the load operation is successful, False otherwise.
3637        """
3638        if path is None:
3639            path = self.path()
3640        try:
3641            if os.path.exists(path):
3642                with open(path, 'r', encoding='utf-8') as stream:
3643                    file = stream.read()
3644                    data, hashed = self.split_at_last_symbol(file, '//')
3645                    if hash_required:
3646                        assert hashed
3647                        if debug:
3648                            print('[debug-load]', hashed)
3649                        new_hash = self.hash_data(data.encode())
3650                        if debug:
3651                            print('[debug-load]', new_hash)
3652                        assert hashed == new_hash, "Hash verification failed. File may be corrupted."
3653                    self.__vault = self.load_vault_from_json(data)
3654                return True
3655            else:
3656                print(f'File not found: {path}')
3657                return False
3658        except (IOError, OSError) as e:
3659            print(f'Error loading file: {e}')
3660            return False
3661
3662    def import_csv_cache_path(self):
3663        """
3664        Generates the cache file path for imported CSV data.
3665
3666        This function constructs the file path where cached data from CSV imports
3667        will be stored. The cache file is a json file (.json extension) appended
3668        to the base path of the object.
3669
3670        Parameters:
3671        None
3672
3673        Returns:
3674        - str: The full path to the import CSV cache file.
3675
3676        Example:
3677        ```bash
3678        >>> obj = ZakatTracker('/data/reports')
3679        >>> obj.import_csv_cache_path()
3680        '/data/reports.import_csv.json'
3681        ```
3682        """
3683        path = str(self.path())
3684        ext = self.ext()
3685        ext_len = len(ext)
3686        if path.endswith(f'.{ext}'):
3687            path = path[:-ext_len - 1]
3688        _, filename = os.path.split(path + f'.import_csv.{ext}')
3689        return self.base_path(filename)
3690
3691    @staticmethod
3692    def get_transaction_csv_headers() -> list[str]:
3693        """
3694        Returns a list of strings representing the headers for a transaction CSV file.
3695
3696        The headers include:
3697        - account: The account associated with the transaction.
3698        - desc: A description of the transaction.
3699        - value: The monetary value of the transaction.
3700        - date: The date of the transaction.
3701        - rate: The applicable rate (if any) for the transaction.
3702        - reference: An optional reference number or identifier for the transaction.
3703
3704        Returns:
3705        - list[str]: A list containing the CSV header strings.
3706        """
3707        return [
3708            "account",
3709            "desc",
3710            "value",
3711            "date",
3712            "rate",
3713            "reference",
3714        ]
3715
3716    def import_csv(self, path: str = 'file.csv', scale_decimal_places: int = 0, delimiter: str = ',', debug: bool = False) -> ImportReport:
3717        """
3718        The function reads the CSV file, checks for duplicate transactions and tries it's best to creates the transactions history accordingly in the system.
3719
3720        Parameters:
3721        - path (str, optional): The path to the CSV file. Default is 'file.csv'.
3722        - scale_decimal_places (int, optional): The number of decimal places to scale the value. Default is 0.
3723        - delimiter (str, optional): The delimiter character to use in the CSV file. Defaults to ','.
3724        - debug (bool, optional): A flag indicating whether to print debug information.
3725
3726        Returns:
3727        - ImportReport: A dataclass containing the number of transactions created, the number of transactions found in the cache,
3728                and a dictionary of bad transactions.
3729
3730        Notes:
3731        * Currency Pair Assumption: This function assumes that the exchange rates stored for each account
3732                                    are appropriate for the currency pairs involved in the conversions.
3733        * The exchange rate for each account is based on the last encountered transaction rate that is not equal
3734            to 1.0 or the previous rate for that account.
3735        * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent
3736            transactions of the same account within the whole imported and existing dataset when doing `transfer`, `check` and
3737            `zakat` operations.
3738
3739        Example:
3740            The CSV file should have the following format, rate and reference are optionals per transaction:
3741            account, desc, value, date, rate, reference
3742            For example:
3743            safe-45, 'Some text', 34872, 1988-06-30 00:00:00.000000, 1, 6554
3744        """
3745        if debug:
3746            print('import_csv', f'debug={debug}')
3747        cache: list[int] = []
3748        try:
3749            if not self.memory_mode():
3750                with open(self.import_csv_cache_path(), 'r', encoding='utf-8') as stream:
3751                    cache = json.load(stream)
3752        except Exception as e:
3753            if debug:
3754                print(e)
3755        date_formats = [
3756            '%Y-%m-%d %H:%M:%S.%f',
3757            '%Y-%m-%dT%H:%M:%S.%f',
3758            '%Y-%m-%dT%H%M%S.%f',
3759            '%Y-%m-%d',
3760        ]
3761        statistics = ImportStatistics(0, 0, 0)
3762        data: dict[int, list[CSVRecord]] = {}
3763        with open(path, newline='', encoding='utf-8') as f:
3764            i = 0
3765            for row in csv.reader(f, delimiter=delimiter):
3766                if debug:
3767                    print(f"csv_row({i})", row, type(row))
3768                if row == self.get_transaction_csv_headers():
3769                    continue
3770                i += 1
3771                hashed = hash(tuple(row))
3772                if hashed in cache:
3773                    statistics.found += 1
3774                    continue
3775                account = row[0]
3776                desc = row[1]
3777                value = float(row[2])
3778                rate = 1.0
3779                reference = ''
3780                if row[4:5]: # Empty list if index is out of range
3781                    rate = float(row[4])
3782                if row[5:6]:
3783                    reference = row[5]
3784                date: int = 0
3785                for time_format in date_formats:
3786                    try:
3787                        date_str = row[3]
3788                        if "." not in date_str:
3789                            date_str += ".000000"
3790                        date = Time.time(datetime.datetime.strptime(date_str, time_format))
3791                        break
3792                    except Exception as e:
3793                        if debug:
3794                            print(e)
3795                record = CSVRecord(
3796                    index=i,
3797                    account=account,
3798                    desc=desc,
3799                    value=value,
3800                    date=date,
3801                    rate=rate,
3802                    reference=reference,
3803                    hashed=hashed,
3804                    error='',
3805                )
3806                if date <= 0:
3807                    record.error = 'invalid date'
3808                    statistics.bad += 1
3809                if value == 0:
3810                    record.error = 'invalid value'
3811                    statistics.bad += 1
3812                    continue
3813                if date not in data:
3814                    data[date] = []
3815                data[date].append(record)
3816
3817        if debug:
3818            print('import_csv', len(data))
3819
3820        if statistics.bad > 0:
3821            return ImportReport(
3822                statistics=statistics,
3823                bad=[
3824                    item
3825                    for sublist in data.values()
3826                    for item in sublist
3827                    if item.error
3828                ],
3829            )
3830
3831        no_lock = self.nolock()
3832        lock = self.__lock()
3833        names = self.names()
3834
3835        # sync accounts
3836        if debug:
3837            print('before-names', names, len(names))
3838        for date, rows in sorted(data.items()):
3839            new_rows: list[CSVRecord] = []
3840            for row in rows:
3841                if row.account not in names:
3842                    account_id = self.create_account(row.account)
3843                    names[row.account] = account_id
3844                account_id = names[row.account]
3845                assert account_id
3846                row.account = account_id
3847                new_rows.append(row)
3848            assert new_rows
3849            assert date in data
3850            data[date] = new_rows
3851        if debug:
3852            print('after-names', names, len(names))
3853            assert names == self.names()
3854
3855        # do ops
3856        for date, rows in sorted(data.items()):
3857            try:
3858                def process(x: CSVRecord):
3859                    x.value = self.unscale(
3860                        x.value,
3861                        decimal_places=scale_decimal_places,
3862                    ) if scale_decimal_places > 0 else x.value
3863                    if x.rate > 0:
3864                        self.exchange(account=x.account, created_time_ns=x.date, rate=x.rate)
3865                    if x.value > 0:
3866                        self.track(unscaled_value=x.value, desc=x.desc, account=x.account, created_time_ns=x.date)
3867                    elif x.value < 0:
3868                        self.subtract(unscaled_value=-x.value, desc=x.desc, account=x.account, created_time_ns=x.date)
3869                    return x.hashed
3870                len_rows = len(rows)
3871                # If records are found at the same time with different accounts in the same amount
3872                # (one positive and the other negative), this indicates it is a transfer.
3873                if len_rows > 2 or len_rows == 1:
3874                    i = 0
3875                    for row in rows:
3876                        row.date += i
3877                        i += 1
3878                        hashed = process(row)
3879                        assert hashed not in cache
3880                        cache.append(hashed)
3881                        statistics.created += 1
3882                    continue
3883                x1 = rows[0]
3884                x2 = rows[1]
3885                if x1.account == x2.account:
3886                    continue
3887                    # raise Exception(f'invalid transfer')
3888                # not transfer - same time - normal ops
3889                if abs(x1.value) != abs(x2.value) and x1.date == x2.date:
3890                    rows[1].date += 1
3891                    for row in rows:
3892                        hashed = process(row)
3893                        assert hashed not in cache
3894                        cache.append(hashed)
3895                        statistics.created += 1
3896                    continue
3897                if x1.rate > 0:
3898                    self.exchange(x1.account, created_time_ns=x1.date, rate=x1.rate)
3899                if x2.rate > 0:
3900                    self.exchange(x2.account, created_time_ns=x2.date, rate=x2.rate)
3901                x1.value = self.unscale(
3902                    x1.value,
3903                    decimal_places=scale_decimal_places,
3904                ) if scale_decimal_places > 0 else x1.value
3905                x2.value = self.unscale(
3906                    x2.value,
3907                    decimal_places=scale_decimal_places,
3908                ) if scale_decimal_places > 0 else x2.value
3909                # just transfer
3910                values = {
3911                    x1.value: x1.account,
3912                    x2.value: x2.account,
3913                }
3914                if debug:
3915                    print('values', values)
3916                if len(values) <= 1:
3917                    continue
3918                self.transfer(
3919                    unscaled_amount=abs(x1.value),
3920                    from_account=values[min(values.keys())],
3921                    to_account=values[max(values.keys())],
3922                    desc=x1.desc,
3923                    created_time_ns=x1.date,
3924                )
3925            except Exception as e:
3926                for row in rows:
3927                    row.error = str(e)
3928                break
3929        if not self.memory_mode():
3930            with open(self.import_csv_cache_path(), 'w', encoding='utf-8') as stream:
3931                stream.write(json.dumps(cache))
3932        if no_lock:
3933            assert lock is not None
3934            self.free(lock)
3935        report = ImportReport(
3936            statistics=statistics,
3937            bad=[
3938                item
3939                for sublist in data.values()
3940                for item in sublist
3941                if item.error
3942            ],
3943        )
3944        if debug:
3945            debug_path = f'{self.import_csv_cache_path()}.debug.json'
3946            with open(debug_path, 'w', encoding='utf-8') as file:
3947                json.dump(report, file, indent=4, cls=JSONEncoder)
3948                print(f'generated debug report @ `{debug_path}`...')
3949        return report
3950
3951    ########
3952    # TESTS #
3953    #######
3954
3955    @staticmethod
3956    def human_readable_size(size: float, decimal_places: int = 2) -> str:
3957        """
3958        Converts a size in bytes to a human-readable format (e.g., KB, MB, GB).
3959
3960        This function iterates through progressively larger units of information
3961        (B, KB, MB, GB, etc.) and divides the input size until it fits within a
3962        range that can be expressed with a reasonable number before the unit.
3963
3964        Parameters:
3965        - size (float): The size in bytes to convert.
3966        - decimal_places (int, optional): The number of decimal places to display
3967            in the result. Defaults to 2.
3968
3969        Returns:
3970        - str: A string representation of the size in a human-readable format,
3971            rounded to the specified number of decimal places. For example:
3972                - '1.50 KB' (1536 bytes)
3973                - '23.00 MB' (24117248 bytes)
3974                - '1.23 GB' (1325899906 bytes)
3975        """
3976        if type(size) not in (float, int):
3977            raise TypeError('size must be a float or integer')
3978        if type(decimal_places) != int:
3979            raise TypeError('decimal_places must be an integer')
3980        for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']:
3981            if size < 1024.0:
3982                break
3983            size /= 1024.0
3984        return f'{size:.{decimal_places}f} {unit}'
3985
3986    @staticmethod
3987    def get_dict_size(obj: dict, seen: Optional[set] = None) -> float:
3988        """
3989        Recursively calculates the approximate memory size of a dictionary and its contents in bytes.
3990
3991        This function traverses the dictionary structure, accounting for the size of keys, values,
3992        and any nested objects. It handles various data types commonly found in dictionaries
3993        (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case
3994        of circular references.
3995
3996        Parameters:
3997        - obj (dict): The dictionary whose size is to be calculated.
3998        - seen (set, optional): A set used internally to track visited objects
3999                             and avoid circular references. Defaults to None.
4000
4001        Returns:
4002         - float: An approximate size of the dictionary and its contents in bytes.
4003
4004        Notes:
4005        - This function is a method of the `ZakatTracker` class and is likely used to
4006          estimate the memory footprint of data structures relevant to Zakat calculations.
4007        - The size calculation is approximate as it relies on `sys.getsizeof()`, which might
4008          not account for all memory overhead depending on the Python implementation.
4009        - Circular references are handled to prevent infinite recursion.
4010        - Basic numeric types (int, float, complex) are assumed to have fixed sizes.
4011        - String sizes are estimated based on character length and encoding.
4012        """
4013        size = 0
4014        if seen is None:
4015            seen = set()
4016
4017        obj_id = id(obj)
4018        if obj_id in seen:
4019            return 0
4020
4021        seen.add(obj_id)
4022        size += sys.getsizeof(obj)
4023
4024        if isinstance(obj, dict):
4025            for k, v in obj.items():
4026                size += ZakatTracker.get_dict_size(k, seen)
4027                size += ZakatTracker.get_dict_size(v, seen)
4028        elif isinstance(obj, (list, tuple, set, frozenset)):
4029            for item in obj:
4030                size += ZakatTracker.get_dict_size(item, seen)
4031        elif isinstance(obj, (int, float, complex)):  # Handle numbers
4032            pass  # Basic numbers have a fixed size, so nothing to add here
4033        elif isinstance(obj, str):  # Handle strings
4034            size += len(obj) * sys.getsizeof(str().encode())  # Size per character in bytes
4035        return size
4036
4037    @staticmethod
4038    def day_to_time(day: int, month: int = 6, year: int = 2024) -> Timestamp:  # افتراض أن الشهر هو يونيو والسنة 2024
4039        """
4040        Convert a specific day, month, and year into a timestamp.
4041
4042        Parameters:
4043        - day (int): The day of the month.
4044        - month (int, optional): The month of the year. Default is 6 (June).
4045        - year (int, optional): The year. Default is 2024.
4046
4047        Returns:
4048        - Timestamp: The timestamp representing the given day, month, and year.
4049
4050        Note:
4051        - This method assumes the default month and year if not provided.
4052        """
4053        return Time.time(datetime.datetime(year, month, day))
4054
4055    @staticmethod
4056    def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
4057        """
4058        Generate a random date between two given dates.
4059
4060        Parameters:
4061        - start_date (datetime.datetime): The start date from which to generate a random date.
4062        - end_date (datetime.datetime): The end date until which to generate a random date.
4063
4064        Returns:
4065        - datetime.datetime: A random date between the start_date and end_date.
4066        """
4067        time_between_dates = end_date - start_date
4068        days_between_dates = time_between_dates.days
4069        random_number_of_days = random.randrange(days_between_dates)
4070        return start_date + datetime.timedelta(days=random_number_of_days)
4071
4072    @staticmethod
4073    def generate_random_csv_file(path: str = 'data.csv',
4074                                 count: int = 1_000,
4075                                 with_rate: bool = False,
4076                                 delimiter: str = ',',
4077                                 debug: bool = False) -> int:
4078        """
4079        Generate a random CSV file with specified parameters.
4080        The function generates a CSV file at the specified path with the given count of rows.
4081        Each row contains a randomly generated account, description, value, and date.
4082        The value is randomly generated between 1000 and 100000,
4083        and the date is randomly generated between 1950-01-01 and 2023-12-31.
4084        If the row number is not divisible by 13, the value is multiplied by -1.
4085
4086        Parameters:
4087        - path (str, optional): The path where the CSV file will be saved. Default is 'data.csv'.
4088        - count (int, optional): The number of rows to generate in the CSV file. Default is 1000.
4089        - with_rate (bool, optional): If True, a random rate between 1.2% and 12% is added. Default is False.
4090        - delimiter (str, optional): The delimiter character to use in the CSV file. Defaults to ','.
4091        - debug (bool, optional): A flag indicating whether to print debug information.
4092
4093        Returns:
4094        - int: number of generated records.
4095        """
4096        if debug:
4097            print('generate_random_csv_file', f'debug={debug}')
4098        i = 0
4099        with open(path, 'w', newline='', encoding='utf-8') as csvfile:
4100            writer = csv.writer(csvfile, delimiter=delimiter)
4101            writer.writerow(ZakatTracker.get_transaction_csv_headers())
4102            for i in range(count):
4103                account = f'acc-{random.randint(1, count)}'
4104                desc = f'Some text {random.randint(1, count)}'
4105                value = random.randint(1000, 100000)
4106                date = ZakatTracker.generate_random_date(
4107                    datetime.datetime(1000, 1, 1),
4108                    datetime.datetime(2023, 12, 31),
4109                ).strftime('%Y-%m-%d %H:%M:%S.%f' if i % 2 == 0 else '%Y-%m-%d %H:%M:%S')
4110                if not i % 13 == 0:
4111                    value *= -1
4112                row = [account, desc, value, date]
4113                if with_rate:
4114                    rate = random.randint(1, 100) * 0.12
4115                    if debug:
4116                        print('before-append', row)
4117                    row.append(rate)
4118                    if debug:
4119                        print('after-append', row)
4120                if i % 2 == 1:
4121                    row += (Time.time(),)
4122                writer.writerow(row)
4123                i = i + 1
4124        return i
4125
4126    @staticmethod
4127    def create_random_list(max_sum: int, min_value: int = 0, max_value: int = 10):
4128        """
4129        Creates a list of random integers whose sum does not exceed the specified maximum.
4130
4131        Parameters:
4132        - max_sum (int): The maximum allowed sum of the list elements.
4133        - min_value (int, optional): The minimum possible value for an element (inclusive).
4134        - max_value (int, optional): The maximum possible value for an element (inclusive).
4135
4136        Returns:
4137        - A list of random integers.
4138        """
4139        result = []
4140        current_sum = 0
4141
4142        while current_sum < max_sum:
4143            # Calculate the remaining space for the next element
4144            remaining_sum = max_sum - current_sum
4145            # Determine the maximum possible value for the next element
4146            next_max_value = min(remaining_sum, max_value)
4147            # Generate a random element within the allowed range
4148            next_element = random.randint(min_value, next_max_value)
4149            result.append(next_element)
4150            current_sum += next_element
4151
4152        return result
4153
4154    def backup(self, folder_path: str, output_directory: str = "compressed", debug: bool = False) -> Optional[Backup]:
4155        """
4156        Compresses a folder into a .tar.lzma archive.
4157
4158        The archive is named following a specific format:
4159        'zakatdb_v<version>_<YYYYMMDD_HHMMSS>_<sha1hash>.tar.lzma'.  This format
4160        is crucial for the `restore` function, so avoid renaming the files.
4161
4162        Parameters:
4163        - folder_path (str): The path to the folder to be compressed.
4164        - output_directory (str, optional): The directory to save the compressed file.
4165                                        Defaults to "compressed".
4166        - debug (bool, optional): Whether to print debug information. Default is False.
4167
4168        Returns:
4169        - Optional[Backup]: A Backup object containing the path to the created archive
4170                            and its SHA1 hash on success, None on failure.
4171        """
4172        try:
4173            os.makedirs(output_directory, exist_ok=True)
4174            now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
4175
4176            # Create a temporary tar archive in memory to calculate the hash
4177            tar_buffer = io.BytesIO()
4178            with tarfile.open(fileobj=tar_buffer, mode="w") as tar:
4179                tar.add(folder_path, arcname=os.path.basename(folder_path))
4180            tar_buffer.seek(0)
4181            folder_hash = hashlib.sha1(tar_buffer.read()).hexdigest()
4182            output_filename = f"zakatdb_v{self.Version()}_{now}_{folder_hash}.tar.lzma"
4183            output_path = os.path.join(output_directory, output_filename)
4184
4185            # Compress the folder to the final .tar.lzma file
4186            with lzma.open(output_path, "wb") as lzma_file:
4187                tar_buffer.seek(0)  # Reset the buffer
4188                with tarfile.open(fileobj=lzma_file, mode="w") as tar:
4189                    tar.add(folder_path, arcname=os.path.basename(folder_path))
4190
4191            if debug:
4192                print(f"Folder '{folder_path}' has been compressed to '{output_path}'")
4193            return Backup(
4194                path=output_path,
4195                hash=folder_hash,
4196            )
4197        except Exception as e:
4198            print(f"Error during compression: {e}")
4199            return None
4200
4201    def restore(self, tar_lzma_path: str, output_folder_path: str = "uncompressed", debug: bool = False) -> bool:
4202        """
4203        Uncompresses a .tar.lzma archive and verifies its integrity using the SHA1 hash.
4204
4205        The SHA1 hash is extracted from the archive's filename, which must follow
4206        the format: 'zakatdb_v<version>_<YYYYMMDD_HHMMSS>_<sha1hash>.tar.lzma'.
4207        This format is essential for successful restoration.
4208
4209        Parameters:
4210        - tar_lzma_path (str): The path to the .tar.lzma file.
4211        - output_folder_path (str, optional): The directory to extract the contents to.
4212                                            Defaults to "uncompressed".
4213        - debug (bool, optional): Whether to print debug information. Default is False.
4214        
4215        Returns:
4216        - bool: True if the restoration was successful and the hash matches, False otherwise.
4217        """
4218        try:
4219            output_folder_path = pathlib.Path(output_folder_path).resolve()
4220            os.makedirs(output_folder_path, exist_ok=True)
4221            filename = os.path.basename(tar_lzma_path)
4222            match = re.match(r"zakatdb_v([^_]+)_(\d{8}_\d{6})_([a-f0-9]{40})\.tar\.lzma", filename)
4223            if not match:
4224                if debug:
4225                    print(f"Error: Invalid filename format: '{filename}'")
4226                return False
4227
4228            expected_hash_from_filename = match.group(3)
4229
4230            with lzma.open(tar_lzma_path, "rb") as lzma_file:
4231                tar_buffer = io.BytesIO(lzma_file.read())  # Read the entire decompressed tar into memory
4232                with tarfile.open(fileobj=tar_buffer, mode="r") as tar:
4233                    tar.extractall(output_folder_path)
4234                    tar_buffer.seek(0)  # Reset buffer to calculate hash
4235                    extracted_hash = hashlib.sha1(tar_buffer.read()).hexdigest()
4236
4237            new_path = os.path.join(output_folder_path, get_first_directory_inside(output_folder_path))
4238            assert os.path.exists(os.path.join(new_path, f"db.{self.ext()}")), f"Restored db.{self.ext()} not found."
4239            if extracted_hash == expected_hash_from_filename:
4240                if debug:
4241                    print(f"'{filename}' has been successfully uncompressed to '{output_folder_path}' and hash verified from filename.")
4242                now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
4243                old_path = os.path.dirname(self.path())
4244                tmp_path = os.path.join(os.path.dirname(old_path), "tmp_restore", now)
4245                if debug:
4246                    print('[xxx] - old_path:', old_path)
4247                    print('[xxx] - tmp_path:', tmp_path)
4248                    print('[xxx] - new_path:', new_path)
4249                try:
4250                    shutil.move(old_path, tmp_path)
4251                    shutil.move(new_path, old_path)
4252                    assert self.load()
4253                    shutil.rmtree(tmp_path)
4254                    return True
4255                except Exception as e:
4256                    print(f"Error applying the restored files: {e}")
4257                    shutil.move(tmp_path, old_path)
4258                    return False
4259            else:
4260                if debug:
4261                    print(f"Warning: Hash mismatch after uncompressing '{filename}'. Expected from filename: {expected_hash_from_filename}, Got: {extracted_hash}")
4262                # Optionally remove the extracted folder if the hash doesn't match
4263                # shutil.rmtree(output_folder_path, ignore_errors=True)
4264                return False
4265
4266        except Exception as e:
4267            print(f"Error during uncompression or hash check: {e}")
4268            return False
4269
4270    def _test_core(self, restore: bool = False, debug: bool = False):
4271
4272        random.seed(1234567890)
4273
4274        # sanity check - core
4275
4276        assert sorted([6, 0, 9, 3], reverse=False) == [0, 3, 6, 9]
4277        assert sorted([6, 0, 9, 3], reverse=True) == [9, 6, 3, 0]
4278        assert sorted(
4279            {6: '6', 0: '0', 9: '9', 3: '3'}.items(),
4280            reverse=False,
4281        ) == [(0, '0'), (3, '3'), (6, '6'), (9, '9')]
4282        assert sorted(
4283            {6: '6', 0: '0', 9: '9', 3: '3'}.items(),
4284            reverse=True,
4285        ) == [(9, '9'), (6, '6'), (3, '3'), (0, '0')]
4286        assert sorted(
4287            {'6': 6, '0': 0, '9': 9, '3': 3}.items(),
4288            reverse=False,
4289        ) == [('0', 0), ('3', 3), ('6', 6), ('9', 9)]
4290        assert sorted(
4291            {'6': 6, '0': 0, '9': 9, '3': 3}.items(),
4292            reverse=True,
4293        ) == [('9', 9), ('6', 6), ('3', 3), ('0', 0)]
4294
4295        Timestamp.test()
4296        AccountID.test(debug)
4297        Time.test(debug)
4298
4299        # test to prevents setting non-existent attributes
4300
4301        for cls in [
4302            StrictDataclass,
4303            BoxZakat,
4304            Box,
4305            Log,
4306            Account,
4307            Exchange,
4308            History,
4309            BoxPlan,
4310            ZakatSummary,
4311            ZakatReport,
4312            Vault,
4313            AccountPaymentPart,
4314            PaymentParts,
4315            SubtractAge,
4316            SubtractAges,
4317            SubtractReport,
4318            TransferTime,
4319            TransferTimes,
4320            TransferRecord,
4321            ImportStatistics,
4322            CSVRecord,
4323            ImportReport,
4324            SizeInfo,
4325            FileInfo,
4326            FileStats,
4327            TimeSummary,
4328            Transaction,
4329            DailyRecords,
4330            Timeline,
4331        ]:
4332            failed = False
4333            try:
4334                x = cls()
4335                x.x = 123
4336            except:
4337                failed = True
4338            assert failed
4339
4340        # sanity check - random forward time
4341
4342        xlist = []
4343        limit = 1000
4344        for _ in range(limit):
4345            y = Time.time()
4346            z = '-'
4347            if y not in xlist:
4348                xlist.append(y)
4349            else:
4350                z = 'x'
4351            if debug:
4352                print(z, y)
4353        xx = len(xlist)
4354        if debug:
4355            print('count', xx, ' - unique: ', (xx / limit) * 100, '%')
4356        assert limit == xx
4357
4358        # test ZakatTracker.split_at_last_symbol
4359
4360        test_cases = [
4361            ("This is a string @ with a symbol.", '@', ("This is a string ", " with a symbol.")),
4362            ("No symbol here.", '$', ("No symbol here.", "")),
4363            ("Multiple $ symbols $ in the string.", '$', ("Multiple $ symbols ", " in the string.")),
4364            ("Here is a symbol%", '%', ("Here is a symbol", "")),
4365            ("@only a symbol", '@', ("", "only a symbol")),
4366            ("", '#', ("", "")),
4367            ("test/test/test.txt", '/', ("test/test", "test.txt")),
4368            ("abc#def#ghi", "#", ("abc#def", "ghi")),
4369            ("abc", "#", ("abc", "")),
4370            ("//https://test", '//', ("//https:", "test")),
4371        ]
4372        
4373        for data, symbol, expected in test_cases:
4374            result = ZakatTracker.split_at_last_symbol(data, symbol)
4375            assert result == expected, f"Test failed for data='{data}', symbol='{symbol}'. Expected {expected}, got {result}"
4376
4377        # human_readable_size
4378
4379        assert ZakatTracker.human_readable_size(0) == '0.00 B'
4380        assert ZakatTracker.human_readable_size(512) == '512.00 B'
4381        assert ZakatTracker.human_readable_size(1023) == '1023.00 B'
4382
4383        assert ZakatTracker.human_readable_size(1024) == '1.00 KB'
4384        assert ZakatTracker.human_readable_size(2048) == '2.00 KB'
4385        assert ZakatTracker.human_readable_size(5120) == '5.00 KB'
4386
4387        assert ZakatTracker.human_readable_size(1024 ** 2) == '1.00 MB'
4388        assert ZakatTracker.human_readable_size(2.5 * 1024 ** 2) == '2.50 MB'
4389
4390        assert ZakatTracker.human_readable_size(1024 ** 3) == '1.00 GB'
4391        assert ZakatTracker.human_readable_size(1024 ** 4) == '1.00 TB'
4392        assert ZakatTracker.human_readable_size(1024 ** 5) == '1.00 PB'
4393
4394        assert ZakatTracker.human_readable_size(1536, decimal_places=0) == '2 KB'
4395        assert ZakatTracker.human_readable_size(2.5 * 1024 ** 2, decimal_places=1) == '2.5 MB'
4396        assert ZakatTracker.human_readable_size(1234567890, decimal_places=3) == '1.150 GB'
4397
4398        try:
4399            # noinspection PyTypeChecker
4400            ZakatTracker.human_readable_size('not a number')
4401            assert False, 'Expected TypeError for invalid input'
4402        except TypeError:
4403            pass
4404
4405        try:
4406            # noinspection PyTypeChecker
4407            ZakatTracker.human_readable_size(1024, decimal_places='not an int')
4408            assert False, 'Expected TypeError for invalid decimal_places'
4409        except TypeError:
4410            pass
4411
4412        # get_dict_size
4413        assert ZakatTracker.get_dict_size({}) == sys.getsizeof({}), 'Empty dictionary size mismatch'
4414        assert ZakatTracker.get_dict_size({'a': 1, 'b': 2.5, 'c': True}) != sys.getsizeof({}), 'Not Empty dictionary'
4415
4416        # number scale
4417        error = 0
4418        total = 0
4419        for sign in ['', '-']:
4420            for max_i, max_j, decimal_places in [
4421                (101, 101, 2),  # fiat currency minimum unit took 2 decimal places
4422                (1, 1_000, 8),  # cryptocurrency like Satoshi in Bitcoin took 8 decimal places
4423                (1, 1_000, 18)  # cryptocurrency like Wei in Ethereum took 18 decimal places
4424            ]:
4425                for return_type in (
4426                        float,
4427                        decimal.Decimal,
4428                ):
4429                    for i in range(max_i):
4430                        for j in range(max_j):
4431                            total += 1
4432                            num_str = f'{sign}{i}.{j:0{decimal_places}d}'
4433                            num = return_type(num_str)
4434                            scaled = self.scale(num, decimal_places=decimal_places)
4435                            unscaled = self.unscale(scaled, return_type=return_type, decimal_places=decimal_places)
4436                            if debug:
4437                                print(
4438                                    f'return_type: {return_type}, num_str: {num_str} - num: {num} - scaled: {scaled} - unscaled: {unscaled}')
4439                            if unscaled != num:
4440                                if debug:
4441                                    print('***** SCALE ERROR *****')
4442                                error += 1
4443        if debug:
4444            print(f'total: {total}, error({error}): {100 * error / total}%')
4445        assert error == 0
4446
4447        # test lock
4448
4449        assert self.nolock()
4450        assert self.__history() is True
4451        lock = self.lock()
4452        assert lock is not None
4453        assert lock > 0
4454        failed = False
4455        try:
4456            self.lock()
4457        except:
4458            failed = True
4459        assert failed
4460        assert self.free(lock)
4461        assert not self.free(lock)
4462
4463        wallet_account_id = self.create_account('wallet')
4464
4465        table = {
4466            AccountID('1'): [
4467                (0, 10, 1000, 1000, 1000, 1, 1),
4468                (0, 20, 3000, 3000, 3000, 2, 2),
4469                (0, 30, 6000, 6000, 6000, 3, 3),
4470                (1, 15, 4500, 4500, 4500, 3, 4),
4471                (1, 50, -500, -500, -500, 4, 5),
4472                (1, 100, -10500, -10500, -10500, 5, 6),
4473            ],
4474            wallet_account_id: [
4475                (1, 90, -9000, -9000, -9000, 1, 1),
4476                (0, 100, 1000, 1000, 1000, 2, 2),
4477                (1, 190, -18000, -18000, -18000, 3, 3),
4478                (0, 1000, 82000, 82000, 82000, 4, 4),
4479            ],
4480        }
4481        for x in table:
4482            for y in table[x]:
4483                lock = self.lock()
4484                if y[0] == 0:
4485                    ref = self.track(
4486                        unscaled_value=y[1],
4487                        desc='test-add',
4488                        account=x,
4489                        created_time_ns=Time.time(),
4490                        debug=debug,
4491                    )
4492                else:
4493                    report = self.subtract(
4494                        unscaled_value=y[1],
4495                        desc='test-sub',
4496                        account=x,
4497                        created_time_ns=Time.time(),
4498                    )
4499                    ref = report.log_ref
4500                    if debug:
4501                        print('_sub', z, Time.time())
4502                assert ref != 0
4503                assert len(self.__vault.account[x].log[ref].file) == 0
4504                for i in range(3):
4505                    file_ref = self.add_file(x, ref, 'file_' + str(i))
4506                    assert file_ref != 0
4507                    if debug:
4508                        print('ref', ref, 'file', file_ref)
4509                    assert len(self.__vault.account[x].log[ref].file) == i + 1
4510                    assert file_ref in self.__vault.account[x].log[ref].file
4511                file_ref = self.add_file(x, ref, 'file_' + str(3))
4512                assert self.remove_file(x, ref, file_ref)
4513                timeline = self.timeline(debug=debug)
4514                if debug:
4515                    print('timeline', timeline)
4516                assert timeline.daily
4517                assert timeline.weekly
4518                assert timeline.monthly
4519                assert timeline.yearly
4520                z = self.balance(x)
4521                if debug:
4522                    print('debug-0', z, y)
4523                assert z == y[2]
4524                z = self.balance(x, False)
4525                if debug:
4526                    print('debug-1', z, y[3])
4527                assert z == y[3]
4528                o = self.__vault.account[x].log
4529                z = 0
4530                for i in o:
4531                    z += o[i].value
4532                if debug:
4533                    print('debug-2', z, type(z))
4534                    print('debug-2', y[4], type(y[4]))
4535                assert z == y[4]
4536                if debug:
4537                    print('debug-2 - PASSED')
4538                assert self.box_size(x) == y[5]
4539                assert self.log_size(x) == y[6]
4540                assert not self.nolock()
4541                assert lock is not None
4542                self.free(lock)
4543                assert self.nolock()
4544            assert self.boxes(x) != {}
4545            assert self.logs(x) != {}
4546
4547            assert not self.hide(x)
4548            assert self.hide(x, False) is False
4549            assert self.hide(x) is False
4550            assert self.hide(x, True)
4551            assert self.hide(x)
4552
4553            assert self.zakatable(x)
4554            assert self.zakatable(x, False) is False
4555            assert self.zakatable(x) is False
4556            assert self.zakatable(x, True)
4557            assert self.zakatable(x)
4558
4559        if restore is True:
4560            # invalid restore point
4561            for lock in [0, time.time_ns(), Time.time()]:
4562                failed = False
4563                try:
4564                    self.recall(dry=True, lock=lock)
4565                except:
4566                    failed = True
4567                assert failed
4568            count = len(self.__vault.history)
4569            if debug:
4570                print('history-count', count)
4571            assert count == 12
4572            # try mode
4573            for _ in range(count):
4574                assert self.recall(dry=True, debug=debug)
4575            count = len(self.__vault.history)
4576            if debug:
4577                print('history-count', count)
4578            assert count == 12
4579            _accounts = list(table.keys())
4580            accounts_limit = len(_accounts) + 1
4581            for i in range(-1, -accounts_limit, -1):
4582                account = _accounts[i]
4583                if debug:
4584                    print(account, len(table[account]))
4585                transaction_limit = len(table[account]) + 1
4586                for j in range(-1, -transaction_limit, -1):
4587                    row = table[account][j]
4588                    if debug:
4589                        print(row, self.balance(account), self.balance(account, False))
4590                    assert self.balance(account) == self.balance(account, False)
4591                    assert self.balance(account) == row[2]
4592                    assert self.recall(dry=False, debug=debug)
4593            assert self.recall(dry=False, debug=debug)
4594            assert self.recall(dry=False, debug=debug)
4595            assert not self.recall(dry=False, debug=debug)
4596            count = len(self.__vault.history)
4597            if debug:
4598                print('history-count', count)
4599            assert count == 0
4600            self.reset()
4601
4602    def _test_storage(self, account_id: Optional[AccountID] = None, debug: bool = False):
4603        old_vault = dataclasses.replace(self.__vault)
4604        old_vault_deep = copy.deepcopy(self.__vault)
4605        old_vault_dict = dataclasses.asdict(self.__vault)
4606        _path = self.path(f'./zakat_test_db/test.{self.ext()}')
4607        if os.path.exists(_path):
4608            os.remove(_path)
4609        for hashed in [False, True]:
4610            self.save(hash_required=hashed)
4611            assert os.path.getsize(_path) > 0
4612            self.reset()
4613            assert self.recall(dry=False, debug=debug) is False
4614            for hash_required in [False, True]:
4615                if debug:
4616                    print(f'[storage] save({hashed}) and load({hash_required}) = {hashed and hash_required}')
4617                self.load(hash_required=hashed and hash_required)
4618                if debug:
4619                    print('[debug]', type(self.__vault))
4620                assert self.__vault.account is not None
4621                assert old_vault == self.__vault
4622                assert old_vault_deep == self.__vault
4623                assert old_vault_dict == dataclasses.asdict(self.__vault)
4624                if account_id is not None:
4625                    # corrupt the data
4626                    log_ref = None
4627                    tmp_file_ref = Time.time()
4628                    for k in self.__vault.account[account_id].log:
4629                        log_ref = k
4630                        self.__vault.account[account_id].log[k].file[tmp_file_ref] = 'HACKED'
4631                        break
4632                    assert old_vault != self.__vault
4633                    assert old_vault_deep != self.__vault
4634                    assert old_vault_dict != dataclasses.asdict(self.__vault)
4635                    # fix the data
4636                    del self.__vault.account[account_id].log[log_ref].file[tmp_file_ref]
4637                    assert old_vault == self.__vault
4638                    assert old_vault_deep == self.__vault
4639                    assert old_vault_dict == dataclasses.asdict(self.__vault)
4640            if hashed:
4641                continue
4642            failed = False
4643            try:
4644                hash_required = True
4645                if debug:
4646                    print(f'x [storage] save({hashed}) and load({hash_required}) = {hashed and hash_required}')
4647                self.load(hash_required=True)
4648            except:
4649                failed = True
4650            assert failed
4651
4652        compressed_dir = "test_compressed"
4653        extracted_dir = "test_extracted"
4654
4655        old_vault = dataclasses.replace(self.__vault)
4656        old_vault_deep = copy.deepcopy(self.__vault)
4657        old_vault_dict = dataclasses.asdict(self.__vault)
4658
4659        # Test backup
4660        backup = self.backup(os.path.dirname(self.path()), compressed_dir, debug=debug)
4661        assert backup.path is not None, "Backup should create a file."
4662        assert backup.hash is not None, "Backup should return a hash."
4663        assert os.path.exists(backup.path), f"Backup file not found at {backup.path}"
4664        assert backup.path.startswith(os.path.join(compressed_dir, f"zakatdb_v{self.Version()}")), "Backup filename should start with zakatdb_v<version>"
4665        assert backup.hash in backup.path, "Backup filename should contain the hash."
4666
4667        # Test restore
4668        restore_successful = self.restore(backup.path, extracted_dir, debug=debug)
4669        assert restore_successful, "Restore should be successful when hash matches."
4670
4671        assert old_vault == self.__vault
4672        assert old_vault_deep == self.__vault
4673        assert old_vault_dict == dataclasses.asdict(self.__vault)
4674
4675        # Test restore with incorrect filename format
4676        invalid_backup_path = os.path.join(compressed_dir, "invalid_name.tar.lzma")
4677        with open(invalid_backup_path, "w") as f:
4678            f.write("")  # Create an empty file
4679        restore_failed_format = self.restore(invalid_backup_path, "temp_extract", debug=debug)
4680        assert not restore_failed_format, "Restore should fail with incorrect filename format."
4681        if os.path.exists("temp_extract"):
4682            shutil.rmtree("temp_extract")
4683
4684        assert old_vault == self.__vault
4685        assert old_vault_deep == self.__vault
4686        assert old_vault_dict == dataclasses.asdict(self.__vault)
4687
4688        # Clean up test files and directories
4689        if not debug:
4690            if os.path.exists(compressed_dir):
4691                shutil.rmtree(compressed_dir)
4692            if os.path.exists(extracted_dir):
4693                shutil.rmtree(extracted_dir)
4694
4695    def test(self, debug: bool = False) -> bool:
4696        if debug:
4697            print('test', f'debug={debug}')
4698        try:
4699
4700            self._test_core(True, debug)
4701            self._test_core(False, debug)
4702
4703            # test_names
4704            self.reset()
4705            x = "test_names"
4706            failed = False
4707            try:
4708                assert self.name(x) == ''
4709            except:
4710                failed = True
4711            assert failed
4712            assert self.names() == {}
4713            failed = False
4714            try:
4715                assert self.name(x, 'qwe') == ''
4716            except:
4717                failed = True
4718            assert failed
4719            account_id0 = self.create_account(x)
4720            assert isinstance(account_id0, AccountID)
4721            assert int(account_id0) > 0
4722            assert self.name(account_id0) == x
4723            assert self.name(account_id0, 'qwe') == 'qwe'
4724            if debug:
4725                print(self.names(keyword='qwe'))
4726            assert self.names(keyword='asd') == {}
4727            assert self.names(keyword='qwe') == {'qwe': account_id0}
4728
4729            # test_create_account
4730            account_name = "test_account"
4731            assert self.names(keyword=account_name) == {}
4732            account_id = self.create_account(account_name)
4733            assert isinstance(account_id, AccountID)
4734            assert int(account_id) > 0
4735            assert account_id in self.__vault.account
4736            assert self.name(account_id) == account_name
4737            assert self.names(keyword=account_name) == {account_name: account_id}
4738
4739            failed = False
4740            try:
4741                self.create_account(account_name)
4742            except:
4743                failed = True
4744            assert failed
4745
4746            # bad are names is forbidden
4747
4748            for bad_name in [
4749                None,
4750                '',
4751                Time.time(),
4752                -Time.time(),
4753                f'{Time.time()}',
4754                f'{-Time.time()}',
4755                0.0,
4756                '0.0',
4757                ' ',
4758            ]:
4759                failed = False
4760                try:
4761                    self.create_account(bad_name)
4762                except:
4763                    failed = True
4764                assert failed
4765
4766            # rename account
4767            assert self.name(account_id) == account_name
4768            assert self.name(account_id, 'asd') == 'asd'
4769            assert self.name(account_id) == 'asd'
4770            # use old and not used name
4771            account_id2 = self.create_account(account_name)
4772            assert int(account_id2) > 0
4773            assert account_id != account_id2
4774            assert self.name(account_id2) == account_name
4775            assert self.names(keyword=account_name) == {account_name: account_id2}
4776
4777            assert self.__history()
4778            count = len(self.__vault.history)
4779            if debug:
4780                print('history-count', count)
4781            assert count == 8
4782
4783            assert self.recall(dry=False, debug=debug)
4784            assert self.name(account_id2) == ''
4785            assert self.account_exists(account_id2)
4786            assert self.recall(dry=False, debug=debug)
4787            assert not self.account_exists(account_id2)
4788            assert self.recall(dry=False, debug=debug)
4789            assert self.name(account_id) == account_name
4790            assert self.recall(dry=False, debug=debug)
4791            assert self.account_exists(account_id)
4792            assert self.recall(dry=False, debug=debug)
4793            assert not self.account_exists(account_id)
4794            assert self.names(keyword='qwe') == {'qwe': account_id0}
4795            assert self.recall(dry=False, debug=debug)
4796            assert self.names(keyword='qwe') == {}
4797            assert self.name(account_id0) == x
4798            assert self.recall(dry=False, debug=debug)
4799            assert self.name(account_id0) == ''
4800            assert self.account_exists(account_id0)
4801            assert self.recall(dry=False, debug=debug)
4802            assert not self.account_exists(account_id0)
4803            assert not self.recall(dry=False, debug=debug)
4804
4805            # Not allowed for duplicate transactions in the same account and time
4806
4807            created = Time.time()
4808            same_account_id = self.create_account('same')
4809            self.track(100, 'test-1', same_account_id, True, created)
4810            failed = False
4811            try:
4812                self.track(50, 'test-1', same_account_id, True, created)
4813            except:
4814                failed = True
4815            assert failed is True
4816
4817            self.reset()
4818
4819            # Same account transfer
4820            for x in [1, 'a', True, 1.8, None]:
4821                failed = False
4822                try:
4823                    self.transfer(1, x, x, 'same-account', debug=debug)
4824                except:
4825                    failed = True
4826                assert failed is True
4827
4828            # Always preserve box age during transfer
4829
4830            series: list[tuple[int, int]] = [
4831                (30, 4),
4832                (60, 3),
4833                (90, 2),
4834            ]
4835            case = {
4836                3000: {
4837                    'series': series,
4838                    'rest': 15000,
4839                },
4840                6000: {
4841                    'series': series,
4842                    'rest': 12000,
4843                },
4844                9000: {
4845                    'series': series,
4846                    'rest': 9000,
4847                },
4848                18000: {
4849                    'series': series,
4850                    'rest': 0,
4851                },
4852                27000: {
4853                    'series': series,
4854                    'rest': -9000,
4855                },
4856                36000: {
4857                    'series': series,
4858                    'rest': -18000,
4859                },
4860            }
4861
4862            selected_time = Time.time() - ZakatTracker.TimeCycle()
4863            ages_account_id = self.create_account('ages')
4864            future_account_id = self.create_account('future')
4865
4866            for total in case:
4867                if debug:
4868                    print('--------------------------------------------------------')
4869                    print(f'case[{total}]', case[total])
4870                for x in case[total]['series']:
4871                    self.track(
4872                        unscaled_value=x[0],
4873                        desc=f'test-{x} ages',
4874                        account=ages_account_id,
4875                        created_time_ns=selected_time * x[1],
4876                    )
4877
4878                unscaled_total = self.unscale(total)
4879                if debug:
4880                    print('unscaled_total', unscaled_total)
4881                refs = self.transfer(
4882                    unscaled_amount=unscaled_total,
4883                    from_account=ages_account_id,
4884                    to_account=future_account_id,
4885                    desc='Zakat Movement',
4886                    debug=debug,
4887                )
4888
4889                if debug:
4890                    print('refs', refs)
4891
4892                ages_cache_balance = self.balance(ages_account_id)
4893                ages_fresh_balance = self.balance(ages_account_id, False)
4894                rest = case[total]['rest']
4895                if debug:
4896                    print('source', ages_cache_balance, ages_fresh_balance, rest)
4897                assert ages_cache_balance == rest
4898                assert ages_fresh_balance == rest
4899
4900                future_cache_balance = self.balance(future_account_id)
4901                future_fresh_balance = self.balance(future_account_id, False)
4902                if debug:
4903                    print('target', future_cache_balance, future_fresh_balance, total)
4904                    print('refs', refs)
4905                assert future_cache_balance == total
4906                assert future_fresh_balance == total
4907
4908                # TODO: check boxes times for `ages` should equal box times in `future`
4909                for ref in self.__vault.account[ages_account_id].box:
4910                    ages_capital = self.__vault.account[ages_account_id].box[ref].capital
4911                    ages_rest = self.__vault.account[ages_account_id].box[ref].rest
4912                    future_capital = 0
4913                    if ref in self.__vault.account[future_account_id].box:
4914                        future_capital = self.__vault.account[future_account_id].box[ref].capital
4915                    future_rest = 0
4916                    if ref in self.__vault.account[future_account_id].box:
4917                        future_rest = self.__vault.account[future_account_id].box[ref].rest
4918                    if ages_capital != 0 and future_capital != 0 and future_rest != 0:
4919                        if debug:
4920                            print('================================================================')
4921                            print('ages', ages_capital, ages_rest)
4922                            print('future', future_capital, future_rest)
4923                        if ages_rest == 0:
4924                            assert ages_capital == future_capital
4925                        elif ages_rest < 0:
4926                            assert -ages_capital == future_capital
4927                        elif ages_rest > 0:
4928                            assert ages_capital == ages_rest + future_capital
4929                self.reset()
4930                assert len(self.__vault.history) == 0
4931
4932            assert self.__history()
4933            assert self.__history(False) is False
4934            assert self.__history() is False
4935            assert self.__history(True)
4936            assert self.__history()
4937            if debug:
4938                print('####################################################################')
4939
4940            wallet_account_id = self.create_account('wallet')
4941            safe_account_id = self.create_account('safe')
4942            bank_account_id = self.create_account('bank')
4943            transaction = [
4944                (
4945                    20, wallet_account_id, AccountID(1), -2000, -2000, -2000, 1, 1,
4946                    2000, 2000, 2000, 1, 1,
4947                ),
4948                (
4949                    750, wallet_account_id, safe_account_id, -77000, -77000, -77000, 2, 2,
4950                    75000, 75000, 75000, 1, 1,
4951                ),
4952                (
4953                    600, safe_account_id, bank_account_id, 15000, 15000, 15000, 1, 2,
4954                    60000, 60000, 60000, 1, 1,
4955                ),
4956            ]
4957            for z in transaction:
4958                lock = self.lock()
4959                x = z[1]
4960                y = z[2]
4961                self.transfer(
4962                    unscaled_amount=z[0],
4963                    from_account=x,
4964                    to_account=y,
4965                    desc='test-transfer',
4966                    debug=debug,
4967                )
4968                zz = self.balance(x)
4969                if debug:
4970                    print(zz, z)
4971                assert zz == z[3]
4972                xx = self.accounts()[x]
4973                assert xx.balance == z[3]
4974                assert self.balance(x, False) == z[4]
4975                assert xx.balance == z[4]
4976
4977                s = 0
4978                log = self.__vault.account[x].log
4979                for i in log:
4980                    s += log[i].value
4981                if debug:
4982                    print('s', s, 'z[5]', z[5])
4983                assert s == z[5]
4984
4985                assert self.box_size(x) == z[6]
4986                assert self.log_size(x) == z[7]
4987
4988                yy = self.accounts()[y]
4989                assert self.balance(y) == z[8]
4990                assert yy.balance == z[8]
4991                assert self.balance(y, False) == z[9]
4992                assert yy.balance == z[9]
4993
4994                s = 0
4995                log = self.__vault.account[y].log
4996                for i in log:
4997                    s += log[i].value
4998                assert s == z[10]
4999
5000                assert self.box_size(y) == z[11]
5001                assert self.log_size(y) == z[12]
5002                assert lock is not None
5003                assert self.free(lock)
5004
5005            assert self.nolock()
5006            history_count = len(self.__vault.history)
5007            transaction_count = len(transaction)
5008            if debug:
5009                print('history-count', history_count, transaction_count)
5010            assert history_count == transaction_count * 3
5011            assert not self.free(Time.time())
5012            assert self.free(self.lock())
5013            assert self.nolock()
5014            assert len(self.__vault.history) == transaction_count * 3
5015
5016            # recall
5017
5018            assert self.nolock()
5019            for i in range(transaction_count * 3, 0, -1):
5020                assert len(self.__vault.history) == i
5021                assert self.recall(dry=False, debug=debug) is True
5022            assert len(self.__vault.history) == 0
5023            assert self.recall(dry=False, debug=debug) is False
5024            assert len(self.__vault.history) == 0
5025
5026            # exchange
5027
5028            cash_account_id = self.create_account('cash')
5029            self.exchange(cash_account_id, 25, 3.75, '2024-06-25')
5030            self.exchange(cash_account_id, 22, 3.73, '2024-06-22')
5031            self.exchange(cash_account_id, 15, 3.69, '2024-06-15')
5032            self.exchange(cash_account_id, 10, 3.66)
5033
5034            assert self.nolock()
5035
5036            bank_account_id = self.create_account('bank')
5037            for i in range(1, 30):
5038                exchange = self.exchange(cash_account_id, i)
5039                rate, description, created = exchange.rate, exchange.description, exchange.time
5040                if debug:
5041                    print(i, rate, description, created)
5042                assert created
5043                if i < 10:
5044                    assert rate == 1
5045                    assert description is None
5046                elif i == 10:
5047                    assert rate == 3.66
5048                    assert description is None
5049                elif i < 15:
5050                    assert rate == 3.66
5051                    assert description is None
5052                elif i == 15:
5053                    assert rate == 3.69
5054                    assert description is not None
5055                elif i < 22:
5056                    assert rate == 3.69
5057                    assert description is not None
5058                elif i == 22:
5059                    assert rate == 3.73
5060                    assert description is not None
5061                elif i >= 25:
5062                    assert rate == 3.75
5063                    assert description is not None
5064                exchange = self.exchange(bank_account_id, i)
5065                rate, description, created = exchange.rate, exchange.description, exchange.time
5066                if debug:
5067                    print(i, rate, description, created)
5068                assert created
5069                assert rate == 1
5070                assert description is None
5071
5072            assert len(self.__vault.exchange) == 1
5073            assert len(self.exchanges()) == 1
5074            self.__vault.exchange.clear()
5075            assert len(self.__vault.exchange) == 0
5076            assert len(self.exchanges()) == 0
5077            self.reset()
5078
5079            # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية
5080            cash_account_id = self.create_account('cash')
5081            self.exchange(cash_account_id, ZakatTracker.day_to_time(25), 3.75, '2024-06-25')
5082            self.exchange(cash_account_id, ZakatTracker.day_to_time(22), 3.73, '2024-06-22')
5083            self.exchange(cash_account_id, ZakatTracker.day_to_time(15), 3.69, '2024-06-15')
5084            self.exchange(cash_account_id, ZakatTracker.day_to_time(10), 3.66)
5085
5086            assert self.nolock()
5087
5088            test_account_id = self.create_account('test')
5089            for i in [x * 0.12 for x in range(-15, 21)]:
5090                if i <= 0:
5091                    assert self.exchange(test_account_id, Time.time(), i, f'range({i})') == Exchange()
5092                else:
5093                    assert self.exchange(test_account_id, Time.time(), i, f'range({i})') is not Exchange()
5094
5095            assert self.nolock()
5096
5097           # اختبار النتائج باستخدام التواريخ بالنانو ثانية
5098            bank_account_id = self.create_account('bank')
5099            for i in range(1, 31):
5100                timestamp_ns = ZakatTracker.day_to_time(i)
5101                exchange = self.exchange(cash_account_id, timestamp_ns)
5102                rate, description, created = exchange.rate, exchange.description, exchange.time
5103                if debug:
5104                    print(i, rate, description, created)
5105                assert created
5106                if i < 10:
5107                    assert rate == 1
5108                    assert description is None
5109                elif i == 10:
5110                    assert rate == 3.66
5111                    assert description is None
5112                elif i < 15:
5113                    assert rate == 3.66
5114                    assert description is None
5115                elif i == 15:
5116                    assert rate == 3.69
5117                    assert description is not None
5118                elif i < 22:
5119                    assert rate == 3.69
5120                    assert description is not None
5121                elif i == 22:
5122                    assert rate == 3.73
5123                    assert description is not None
5124                elif i >= 25:
5125                    assert rate == 3.75
5126                    assert description is not None
5127                exchange = self.exchange(bank_account_id, i)
5128                rate, description, created = exchange.rate, exchange.description, exchange.time
5129                if debug:
5130                    print(i, rate, description, created)
5131                assert created
5132                assert rate == 1
5133                assert description is None
5134
5135            assert self.nolock()
5136            if debug:
5137                print(self.__vault.history, len(self.__vault.history))
5138            for _ in range(len(self.__vault.history)):
5139                assert self.recall(dry=False, debug=debug)
5140            assert not self.recall(dry=False, debug=debug)
5141
5142            self.reset()
5143
5144            # test transfer between accounts with different exchange rate
5145
5146            a_SAR = self.create_account('Bank (SAR)')
5147            b_USD = self.create_account('Bank (USD)')
5148            c_SAR = self.create_account('Safe (SAR)')
5149            # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer
5150            for case in [
5151                (0, a_SAR, 'SAR Gift', 1000, 100000),
5152                (1, a_SAR, 1),
5153                (0, b_USD, 'USD Gift', 500, 50000),
5154                (1, b_USD, 1),
5155                (2, b_USD, 3.75),
5156                (1, b_USD, 3.75),
5157                (3, 100, b_USD, a_SAR, '100 USD -> SAR', 40000, 137500),
5158                (0, c_SAR, 'Salary', 750, 75000),
5159                (3, 375, c_SAR, b_USD, '375 SAR -> USD', 37500, 50000),
5160                (3, 3.75, a_SAR, b_USD, '3.75 SAR -> USD', 137125, 50100),
5161            ]:
5162                if debug:
5163                    print('case', case)
5164                match (case[0]):
5165                    case 0:  # track
5166                        _, account, desc, x, balance = case
5167                        self.track(unscaled_value=x, desc=desc, account=account, debug=debug)
5168
5169                        cached_value = self.balance(account, cached=True)
5170                        fresh_value = self.balance(account, cached=False)
5171                        if debug:
5172                            print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value)
5173                        assert cached_value == balance
5174                        assert fresh_value == balance
5175                    case 1:  # check-exchange
5176                        _, account, expected_rate = case
5177                        t_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug)
5178                        if debug:
5179                            print('t-exchange', t_exchange)
5180                        assert t_exchange.rate == expected_rate
5181                    case 2:  # do-exchange
5182                        _, account, rate = case
5183                        self.exchange(account, rate=rate, debug=debug)
5184                        b_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug)
5185                        if debug:
5186                            print('b-exchange', b_exchange)
5187                        assert b_exchange.rate == rate
5188                    case 3:  # transfer
5189                        _, x, a, b, desc, a_balance, b_balance = case
5190                        self.transfer(x, a, b, desc, debug=debug)
5191
5192                        cached_value = self.balance(a, cached=True)
5193                        fresh_value = self.balance(a, cached=False)
5194                        if debug:
5195                            print(
5196                                'account', a,
5197                                'cached_value', cached_value,
5198                                'fresh_value', fresh_value,
5199                                'a_balance', a_balance,
5200                            )
5201                        assert cached_value == a_balance
5202                        assert fresh_value == a_balance
5203
5204                        cached_value = self.balance(b, cached=True)
5205                        fresh_value = self.balance(b, cached=False)
5206                        if debug:
5207                            print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value)
5208                        assert cached_value == b_balance
5209                        assert fresh_value == b_balance
5210
5211            # Transfer all in many chunks randomly from B to A
5212            a_SAR_balance = 137125
5213            b_USD_balance = 50100
5214            b_USD_exchange = self.exchange(b_USD)
5215            amounts = ZakatTracker.create_random_list(b_USD_balance, max_value=1000)
5216            if debug:
5217                print('amounts', amounts)
5218            i = 0
5219            for x in amounts:
5220                if debug:
5221                    print(f'{i} - transfer-with-exchange({x})')
5222                self.transfer(
5223                    unscaled_amount=self.unscale(x),
5224                    from_account=b_USD,
5225                    to_account=a_SAR,
5226                    desc=f'{x} USD -> SAR',
5227                    debug=debug,
5228                )
5229
5230                b_USD_balance -= x
5231                cached_value = self.balance(b_USD, cached=True)
5232                fresh_value = self.balance(b_USD, cached=False)
5233                if debug:
5234                    print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
5235                          b_USD_balance)
5236                assert cached_value == b_USD_balance
5237                assert fresh_value == b_USD_balance
5238
5239                a_SAR_balance += int(x * b_USD_exchange.rate)
5240                cached_value = self.balance(a_SAR, cached=True)
5241                fresh_value = self.balance(a_SAR, cached=False)
5242                if debug:
5243                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
5244                          a_SAR_balance, 'rate', b_USD_exchange.rate)
5245                assert cached_value == a_SAR_balance
5246                assert fresh_value == a_SAR_balance
5247                i += 1
5248
5249            # Transfer all in many chunks randomly from C to A
5250            c_SAR_balance = 37500
5251            amounts = ZakatTracker.create_random_list(c_SAR_balance, max_value=1000)
5252            if debug:
5253                print('amounts', amounts)
5254            i = 0
5255            for x in amounts:
5256                if debug:
5257                    print(f'{i} - transfer-with-exchange({x})')
5258                self.transfer(
5259                    unscaled_amount=self.unscale(x),
5260                    from_account=c_SAR,
5261                    to_account=a_SAR,
5262                    desc=f'{x} SAR -> a_SAR',
5263                    debug=debug,
5264                )
5265
5266                c_SAR_balance -= x
5267                cached_value = self.balance(c_SAR, cached=True)
5268                fresh_value = self.balance(c_SAR, cached=False)
5269                if debug:
5270                    print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
5271                          c_SAR_balance)
5272                assert cached_value == c_SAR_balance
5273                assert fresh_value == c_SAR_balance
5274
5275                a_SAR_balance += x
5276                cached_value = self.balance(a_SAR, cached=True)
5277                fresh_value = self.balance(a_SAR, cached=False)
5278                if debug:
5279                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
5280                          a_SAR_balance)
5281                assert cached_value == a_SAR_balance
5282                assert fresh_value == a_SAR_balance
5283                i += 1
5284
5285            assert self.save(f'accounts-transfer-with-exchange-rates.{self.ext()}')
5286
5287            # check & zakat with exchange rates for many cycles
5288
5289            lock = None
5290            safe_account_id = self.create_account('safe')
5291            cave_account_id = self.create_account('cave')
5292            for rate, values in {
5293                1: {
5294                    'in': [1000, 2000, 10000],
5295                    'exchanged': [100000, 200000, 1000000],
5296                    'out': [2500, 5000, 73140],
5297                },
5298                3.75: {
5299                    'in': [200, 1000, 5000],
5300                    'exchanged': [75000, 375000, 1875000],
5301                    'out': [1875, 9375, 137138],
5302                },
5303            }.items():
5304                a, b, c = values['in']
5305                m, n, o = values['exchanged']
5306                x, y, z = values['out']
5307                if debug:
5308                    print('rate', rate, 'values', values)
5309                for case in [
5310                    (a, safe_account_id, Time.time() - ZakatTracker.TimeCycle(), [
5311                        {safe_account_id: {0: {'below_nisab': x}}},
5312                    ], False, m),
5313                    (b, safe_account_id, Time.time() - ZakatTracker.TimeCycle(), [
5314                        {safe_account_id: {0: {'count': 1, 'total': y}}},
5315                    ], True, n),
5316                    (c, cave_account_id, Time.time() - (ZakatTracker.TimeCycle() * 3), [
5317                        {cave_account_id: {0: {'count': 3, 'total': z}}},
5318                    ], True, o),
5319                ]:
5320                    if debug:
5321                        print(f'############# check(rate: {rate}) #############')
5322                        print('case', case)
5323                    self.reset()
5324                    self.exchange(account=case[1], created_time_ns=case[2], rate=rate)
5325                    self.track(
5326                        unscaled_value=case[0],
5327                        desc='test-check',
5328                        account=case[1],
5329                        created_time_ns=case[2],
5330                    )
5331                    assert self.snapshot()
5332
5333                    # assert self.nolock()
5334                    # history_size = len(self.__vault.history)
5335                    # print('history_size', history_size)
5336                    # assert history_size == 2
5337                    lock = self.lock()
5338                    assert lock
5339                    assert not self.nolock()
5340                    assert self.__vault.cache.zakat is None
5341                    report = self.check(2.17, None, debug)
5342                    if debug:
5343                        print('[report]', report)
5344                    assert case[4] == report.valid
5345                    assert case[5] == report.summary.total_wealth
5346                    assert case[5] == report.summary.total_zakatable_amount
5347                    if report.valid:
5348                        assert self.__vault.cache.zakat is not None
5349                        assert report.plan
5350                        assert self.zakat(report, debug=debug)
5351                        assert self.__vault.cache.zakat is None
5352                        if debug:
5353                            pp().pprint(self.__vault)
5354                        self._test_storage(debug=debug)
5355
5356                        for x in report.plan:
5357                            assert case[1] == x
5358                            if report.plan[x][0].below_nisab:
5359                                if debug:
5360                                    print('[assert]', report.plan[x][0].total, case[3][0][x][0]['below_nisab'])
5361                                assert report.plan[x][0].total == case[3][0][x][0]['below_nisab']
5362                            else:
5363                                if debug:
5364                                    print('[assert]', int(report.summary.total_zakat_due), case[3][0][x][0]['total'])
5365                                    print('[assert]', int(report.plan[x][0].total), case[3][0][x][0]['total'])
5366                                    print('[assert]', report.plan[x][0].count ,case[3][0][x][0]['count'])
5367                                assert int(report.summary.total_zakat_due) == case[3][0][x][0]['total']
5368                                assert int(report.plan[x][0].total) == case[3][0][x][0]['total']
5369                                assert report.plan[x][0].count == case[3][0][x][0]['count']
5370                    else:
5371                        assert self.__vault.cache.zakat is None
5372                        result = self.zakat(report, debug=debug)
5373                        if debug:
5374                            print('zakat-result', result, case[4])
5375                        assert result == case[4]
5376                        report = self.check(2.17, None, debug)
5377                        assert report.valid is False
5378            self._test_storage(account_id=cave_account_id, debug=debug)
5379
5380            # recall after zakat
5381
5382            history_size = len(self.__vault.history)
5383            if debug:
5384                print('history_size', history_size)
5385            assert history_size == 3
5386            assert not self.nolock()
5387            assert self.recall(dry=False, debug=debug) is False
5388            self.free(lock)
5389            assert self.nolock()
5390
5391            for i in range(3, 0, -1):
5392                history_size = len(self.__vault.history)
5393                if debug:
5394                    print('history_size', history_size)
5395                assert history_size == i
5396                assert self.recall(dry=False, debug=debug) is True
5397
5398            assert self.nolock()
5399            assert self.recall(dry=False, debug=debug) is False
5400
5401            history_size = len(self.__vault.history)
5402            if debug:
5403                print('history_size', history_size)
5404            assert history_size == 0
5405
5406            account_size = len(self.__vault.account)
5407            if debug:
5408                print('account_size', account_size)
5409            assert account_size == 0
5410
5411            report_size = len(self.__vault.report)
5412            if debug:
5413                print('report_size', report_size)
5414            assert report_size == 0
5415
5416            assert self.nolock()
5417
5418            # csv
5419
5420            csv_count = 1000
5421
5422            for with_rate, path in {
5423                False: 'test-import_csv-no-exchange',
5424                True: 'test-import_csv-with-exchange',
5425            }.items():
5426
5427                if debug:
5428                    print('test_import_csv', with_rate, path)
5429
5430                csv_path = path + '.csv'
5431                if os.path.exists(csv_path):
5432                    os.remove(csv_path)
5433                c = self.generate_random_csv_file(
5434                    path=csv_path,
5435                    count=csv_count,
5436                    with_rate=with_rate,
5437                    debug=debug,
5438                )
5439                if debug:
5440                    print('generate_random_csv_file', c)
5441                assert c == csv_count
5442                assert os.path.getsize(csv_path) > 0
5443                cache_path = self.import_csv_cache_path()
5444                if os.path.exists(cache_path):
5445                    os.remove(cache_path)
5446                self.reset()
5447                lock = self.lock()
5448                import_report = self.import_csv(csv_path, debug=debug)
5449                bad_count = len(import_report.bad)
5450                if debug:
5451                    print(f'csv-imported: {import_report.statistics} = count({csv_count})')
5452                    print('bad', import_report.bad)
5453                assert import_report.statistics.created + import_report.statistics.found + bad_count == csv_count
5454                assert import_report.statistics.created == csv_count
5455                assert bad_count == 0
5456                assert bad_count == import_report.statistics.bad
5457                tmp_size = os.path.getsize(cache_path)
5458                assert tmp_size > 0
5459
5460                import_report_2 = self.import_csv(csv_path, debug=debug)
5461                bad_2_count = len(import_report_2.bad)
5462                if debug:
5463                    print(f'csv-imported: {import_report_2}')
5464                    print('bad', import_report_2.bad)
5465                assert tmp_size == os.path.getsize(cache_path)
5466                assert import_report_2.statistics.created + import_report_2.statistics.found + bad_2_count == csv_count
5467                assert import_report.statistics.created == import_report_2.statistics.found
5468                assert bad_count == bad_2_count
5469                assert import_report_2.statistics.found == csv_count
5470                assert bad_2_count == 0
5471                assert bad_2_count == import_report_2.statistics.bad
5472                assert import_report_2.statistics.created == 0
5473
5474                # payment parts
5475
5476                positive_parts = self.build_payment_parts(100, positive_only=True)
5477                assert self.check_payment_parts(positive_parts) != 0
5478                assert self.check_payment_parts(positive_parts) != 0
5479                all_parts = self.build_payment_parts(300, positive_only=False)
5480                assert self.check_payment_parts(all_parts) != 0
5481                assert self.check_payment_parts(all_parts) != 0
5482                if debug:
5483                    pp().pprint(positive_parts)
5484                    pp().pprint(all_parts)
5485                # dynamic discount
5486                suite = []
5487                count = 3
5488                for exceed in [False, True]:
5489                    case = []
5490                    for part in [positive_parts, all_parts]:
5491                        #part = parts.copy()
5492                        demand = part.demand
5493                        if debug:
5494                            print(demand, part.total)
5495                        i = 0
5496                        z = demand / count
5497                        cp = PaymentParts(
5498                            demand=demand,
5499                            exceed=exceed,
5500                            total=part.total,
5501                        )
5502                        j = ''
5503                        for x, y in part.account.items():
5504                            x_exchange = self.exchange(x)
5505                            zz = self.exchange_calc(z, 1, x_exchange.rate)
5506                            if exceed and zz <= demand:
5507                                i += 1
5508                                y.part = zz
5509                                if debug:
5510                                    print(exceed, y)
5511                                cp.account[x] = y
5512                                case.append(y)
5513                            elif not exceed and y.balance >= zz:
5514                                i += 1
5515                                y.part = zz
5516                                if debug:
5517                                    print(exceed, y)
5518                                cp.account[x] = y
5519                                case.append(y)
5520                            j = x
5521                            if i >= count:
5522                                break
5523                        if debug:
5524                            print('[debug]', j)
5525                            print('[debug]', cp.account[j])
5526                        if cp.account[j] != AccountPaymentPart(0.0, 0.0, 0.0):
5527                            suite.append(cp)
5528                if debug:
5529                    print('suite', len(suite))
5530                for case in suite:
5531                    if debug:
5532                        print('case', case)
5533                    result = self.check_payment_parts(case)
5534                    if debug:
5535                        print('check_payment_parts', result, f'exceed: {exceed}')
5536                    assert result == 0
5537
5538                    assert self.__vault.cache.zakat is None
5539                    report = self.check(2.17, None, debug)
5540                    if debug:
5541                        print('valid', report.valid)
5542                    zakat_result = self.zakat(report, parts=case, debug=debug)
5543                    if debug:
5544                        print('zakat-result', zakat_result)
5545                    assert report.valid == zakat_result
5546                    # test verified zakat report is required
5547                    if zakat_result:
5548                        assert self.__vault.cache.zakat is None
5549                        failed = False
5550                        try:
5551                            self.zakat(report, parts=case, debug=debug)
5552                        except:
5553                            failed = True
5554                        assert failed
5555
5556                assert self.free(lock)
5557
5558            assert self.save(path + f'.{self.ext()}')
5559            assert self.save(f'1000-transactions-test.{self.ext()}')
5560            return True
5561        except Exception as e:
5562            if self.__debug_output:
5563                pp().pprint(self.__vault)
5564                print('============================================================================')
5565                pp().pprint(self.__debug_output)
5566            assert self.save(f'test-snapshot.{self.ext()}')
5567            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, here below is just a demonstration:

__vault (dict):
    - account (dict):
        - {account_id} (dict):
            - balance (int): The current balance of the account.
            - name (str): The name of the account.
            - created (int): The creation time for the account.
            - box (dict): A dictionary storing transaction details.
                - {timestamp} (dict):
                    - capital (int): The initial amount of the transaction.
                    - rest (int): The remaining amount after Zakat deductions and withdrawal.
                    - zakat (dict):
                        - count (int): The number of times Zakat has been calculated for this transaction.
                        - last (int): The timestamp of the last Zakat calculation.
                        - 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_id} (dict):
            - {timestamps} (dict):
                - rate (float): Exchange rate when compared to local currency.
                - description (str): The description of the exchange rate.
    - history (dict):
        - {lock_timestamp} (dict): A list of dictionaries storing the history of actions performed.
            - {order_timestamp} (dict):
                - {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 reference 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)
1488    def __init__(self, db_path: str = './zakat_db/', history_mode: bool = True):
1489        """
1490        Initialize ZakatTracker with database path and history mode.
1491
1492        Parameters:
1493        - db_path (str, optional): The path to the database  directory. Defaults to './zakat_db/'. Use ':memory:' for an in-memory database.
1494        - history_mode (bool, optional): The mode for tracking history. Default is True.
1495
1496        Returns:
1497        None
1498        """
1499        self.reset()
1500        self.__memory_mode = db_path == ':memory:'
1501        self.__history(history_mode)
1502        if not self.__memory_mode:
1503            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:
1398    @staticmethod
1399    def Version() -> str:
1400        """
1401        Returns the current version of the software.
1402
1403        This function returns a string representing the current version of the software,
1404        including major, minor, and patch version numbers in the format 'X.Y.Z'.
1405
1406        Returns:
1407        - str: The current version of the software.
1408        """
1409        version = '0.3.5'
1410        git_hash, unstaged_count, commit_count_since_last_tag = get_git_status()
1411        if git_hash and (unstaged_count > 0 or commit_count_since_last_tag > 0):
1412            version += f".{commit_count_since_last_tag}dev{unstaged_count}+{git_hash}"
1413            print(version)
1414        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:
1416    @staticmethod
1417    def ZakatCut(x: float) -> float:
1418        """
1419        Calculates the Zakat amount due on an asset.
1420
1421        This function calculates the zakat amount due on a given asset value over one lunar year.
1422        Zakat is an Islamic obligatory alms-giving, calculated as a fixed percentage of an individual's wealth
1423        that exceeds a certain threshold (Nisab).
1424
1425        Parameters:
1426        - x (float): The total value of the asset on which Zakat is to be calculated.
1427
1428        Returns:
1429        - float: The amount of Zakat due on the asset, calculated as 2.5% of the asset's value.
1430        """
1431        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:
1433    @staticmethod
1434    def TimeCycle(days: int = 355) -> int:
1435        """
1436        Calculates the approximate duration of a lunar year in nanoseconds.
1437
1438        This function calculates the approximate duration of a lunar year based on the given number of days.
1439        It converts the given number of days into nanoseconds for use in high-precision timing applications.
1440
1441        Parameters:
1442        - days (int, optional): The number of days in a lunar year. Defaults to 355,
1443              which is an approximation of the average length of a lunar year.
1444
1445        Returns:
1446        - int: The approximate duration of a lunar year in nanoseconds.
1447        """
1448        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:
1450    @staticmethod
1451    def Nisab(gram_price: float, gram_quantity: float = 595) -> float:
1452        """
1453        Calculate the total value of Nisab (a unit of weight in Islamic jurisprudence) based on the given price per gram.
1454
1455        This function calculates the Nisab value, which is the minimum threshold of wealth,
1456        that makes an individual liable for paying Zakat.
1457        The Nisab value is determined by the equivalent value of a specific amount
1458        of gold or silver (currently 595 grams in silver) in the local currency.
1459
1460        Parameters:
1461        - gram_price (float): The price per gram of Nisab.
1462        - gram_quantity (float, optional): The quantity of grams in a Nisab. Default is 595 grams of silver.
1463
1464        Returns:
1465        - float: The total value of Nisab based on the given price per gram.
1466        """
1467        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:
1469    @staticmethod
1470    def ext() -> str:
1471        """
1472        Returns the file extension used by the ZakatTracker class.
1473
1474        Parameters:
1475        None
1476
1477        Returns:
1478        - str: The file extension used by the ZakatTracker class, which is 'json'.
1479        """
1480        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:
1505    def memory_mode(self) -> bool:
1506        """
1507        Check if the ZakatTracker is operating in memory mode.
1508
1509        Returns:
1510        - bool: True if the database is in memory, False otherwise.
1511        """
1512        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:
1514    def path(self, path: Optional[str] = None) -> str:
1515        """
1516        Set or get the path to the database file.
1517
1518        If no path is provided, the current path is returned.
1519        If a path is provided, it is set as the new path.
1520        The function also creates the necessary directories if the provided path is a file.
1521
1522        Parameters:
1523        - path (str, optional): The new path to the database file. If not provided, the current path is returned.
1524
1525        Returns:
1526        - str: The current or new path to the database file.
1527        """
1528        if path is None:
1529            return str(self.__vault_path)
1530        self.__vault_path = pathlib.Path(path).resolve()
1531        base_path = pathlib.Path(path).resolve()
1532        if base_path.is_file() or base_path.suffix:
1533            base_path = base_path.parent
1534        base_path.mkdir(parents=True, exist_ok=True)
1535        self.__base_path = base_path
1536        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:
1538    def base_path(self, *args) -> str:
1539        """
1540        Generate a base path by joining the provided arguments with the existing base path.
1541
1542        Parameters:
1543        - *args (str): Variable length argument list of strings to be joined with the base path.
1544
1545        Returns:
1546        - str: The generated base path. If no arguments are provided, the existing base path is returned.
1547        """
1548        if not args:
1549            return str(self.__base_path)
1550        filtered_args = []
1551        ignored_filename = None
1552        for arg in args:
1553            if pathlib.Path(arg).suffix:
1554                ignored_filename = arg
1555            else:
1556                filtered_args.append(arg)
1557        base_path = pathlib.Path(self.__base_path)
1558        full_path = base_path.joinpath(*filtered_args)
1559        full_path.mkdir(parents=True, exist_ok=True)
1560        if ignored_filename is not None:
1561            return full_path.resolve() / ignored_filename  # Join with the ignored filename
1562        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:
1564    @staticmethod
1565    def scale(x: float | int | decimal.Decimal, decimal_places: int = 2) -> int:
1566        """
1567        Scales a numerical value by a specified power of 10, returning an integer.
1568
1569        This function is designed to handle various numeric types (`float`, `int`, or `decimal.Decimal`) and
1570        facilitate precise scaling operations, particularly useful in financial or scientific calculations.
1571
1572        Parameters:
1573        - x (float | int | decimal.Decimal): The numeric value to scale. Can be a floating-point number, integer, or decimal.
1574        - decimal_places (int, optional): The exponent for the scaling factor (10**y). Defaults to 2, meaning the input is scaled
1575            by a factor of 100 (e.g., converts 1.23 to 123).
1576
1577        Returns:
1578        - The scaled value, rounded to the nearest integer.
1579
1580        Raises:
1581        - TypeError: If the input `x` is not a valid numeric type.
1582
1583        Examples:
1584        ```bash
1585        >>> ZakatTracker.scale(3.14159)
1586        314
1587        >>> ZakatTracker.scale(1234, decimal_places=3)
1588        1234000
1589        >>> ZakatTracker.scale(decimal.Decimal('0.005'), decimal_places=4)
1590        50
1591        ```
1592        """
1593        if not isinstance(x, (float, int, decimal.Decimal)):
1594            raise TypeError(f'Input "{x}" must be a float, int, or decimal.Decimal.')
1595        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:
1597    @staticmethod
1598    def unscale(x: int, return_type: type = float, decimal_places: int = 2) -> float | decimal.Decimal:
1599        """
1600        Unscales an integer by a power of 10.
1601
1602        Parameters:
1603        - x (int): The integer to unscale.
1604        - return_type (type, optional): The desired type for the returned value. Can be float, int, or decimal.Decimal. Defaults to float.
1605        - decimal_places (int, optional): The power of 10 to use. Defaults to 2.
1606
1607        Returns:
1608        - float | int | decimal.Decimal: The unscaled number, converted to the specified return_type.
1609
1610        Raises:
1611        - TypeError: If the return_type is not float or decimal.Decimal.
1612        """
1613        if return_type not in (float, decimal.Decimal):
1614            raise TypeError(f'Invalid return_type({return_type}). Supported types are float, int, and decimal.Decimal.')
1615        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:
1617    def reset(self) -> None:
1618        """
1619        Reset the internal data structure to its initial state.
1620
1621        Parameters:
1622        None
1623
1624        Returns:
1625        None
1626        """
1627        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:
1629    def clean_history(self, lock: Optional[Timestamp] = None) -> int:
1630        """
1631        Cleans up the empty history records of actions performed on the ZakatTracker instance.
1632
1633        Parameters:
1634        - lock (Timestamp, optional): The lock ID is used to clean up the empty history.
1635            If not provided, it cleans up the empty history records for all locks.
1636
1637        Returns:
1638        - int: The number of locks cleaned up.
1639        """
1640        count = 0
1641        if lock in self.__vault.history:
1642            if len(self.__vault.history[lock]) <= 0:
1643                count += 1
1644                del self.__vault.history[lock]
1645            return count
1646        for key in self.__vault.history:
1647            if len(self.__vault.history[key]) <= 0:
1648                count += 1
1649                del self.__vault.history[key]
1650        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:
1720    def nolock(self) -> bool:
1721        """
1722        Check if the vault lock is currently not set.
1723
1724        Parameters:
1725        None
1726
1727        Returns:
1728        - bool: True if the vault lock is not set, False otherwise.
1729        """
1730        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]:
1745    def lock(self) -> Optional[Timestamp]:
1746        """
1747        Acquires a lock on the ZakatTracker instance.
1748
1749        Parameters:
1750        None
1751
1752        Returns:
1753        - Optional[Timestamp]: The lock ID. This ID can be used to release the lock later.
1754        """
1755        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:
1757    def steps(self) -> dict:
1758        """
1759        Returns a copy of the history of steps taken in the ZakatTracker.
1760
1761        The history is a dictionary where each key is a unique identifier for a step,
1762        and the corresponding value is a dictionary containing information about the step.
1763
1764        Parameters:
1765        None
1766
1767        Returns:
1768        - dict: A copy of the history of steps taken in the ZakatTracker.
1769        """
1770        return {
1771            lock: {
1772                timestamp: dataclasses.asdict(history)
1773                for timestamp, history in steps.items()
1774            }
1775            for lock, steps in self.__vault.history.items()
1776        }

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:
1778    def free(self, lock: Timestamp, auto_save: bool = True) -> bool:
1779        """
1780        Releases the lock on the database.
1781
1782        Parameters:
1783        - lock (Timestamp): The lock ID to be released.
1784        - auto_save (bool, optional): Whether to automatically save the database after releasing the lock.
1785
1786        Returns:
1787        - bool: True if the lock is successfully released and (optionally) saved, False otherwise.
1788        """
1789        if lock == self.__vault.lock:
1790            self.clean_history(lock)
1791            self.__vault.lock = None
1792            if auto_save and not self.memory_mode():
1793                return self.save(self.path())
1794            return True
1795        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:
1797    def recall(self, dry: bool = True, lock: Optional[Timestamp] = None, debug: bool = False) -> bool:
1798        """
1799        Revert the last operation.
1800
1801        Parameters:
1802        - dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
1803        - lock (Timestamp, optional): An optional lock value to ensure the recall
1804                operation is performed on the expected history entry. If provided,
1805                it checks if the current lock and the most recent history key
1806                match the given lock value. Defaults to None.
1807        - debug (bool, optional): If True, the function will print debug information. Default is False.
1808
1809        Returns:
1810        - bool: True if the operation was successful, False otherwise.
1811        """
1812        if not self.nolock() or len(self.__vault.history) == 0:
1813            return False
1814        if len(self.__vault.history) <= 0:
1815            return False
1816        ref = sorted(self.__vault.history.keys())[-1]
1817        if debug:
1818            print('recall', ref)
1819        memory = sorted(self.__vault.history[ref], reverse=True)
1820        if debug:
1821            print(type(memory), 'memory', memory)
1822        if lock is not None:
1823            assert self.__vault.lock == lock, "Invalid current lock"
1824            assert ref == lock, "Invalid last lock"
1825            assert self.__history(), "History mode should be enabled, found off!!!"
1826        sub_positive_log_negative = 0
1827        for i in memory:
1828            x = self.__vault.history[ref][i]
1829            if debug:
1830                print(type(x), x)
1831            if x.action != Action.REPORT:
1832                assert x.account is not None
1833                if x.action != Action.EXCHANGE:
1834                    assert self.account_exists(x.account)
1835            match x.action:
1836                case Action.CREATE:
1837                    if debug:
1838                        print('account', self.__vault.account[x.account])
1839                    assert len(self.__vault.account[x.account].box) == 0
1840                    assert len(self.__vault.account[x.account].log) == 0
1841                    assert self.__vault.account[x.account].balance == 0
1842                    assert self.__vault.account[x.account].count == 0
1843                    assert self.__vault.account[x.account].name == ''
1844                    if dry:
1845                        continue
1846                    del self.__vault.account[x.account]
1847
1848                case Action.NAME:
1849                    assert x.value is not None
1850                    if dry:
1851                        continue
1852                    self.__vault.account[x.account].name = x.value
1853
1854                case Action.TRACK:
1855                    assert x.value is not None
1856                    assert x.ref is not None
1857                    if dry:
1858                        continue
1859                    self.__vault.account[x.account].balance -= x.value
1860                    self.__vault.account[x.account].count -= 1
1861                    del self.__vault.account[x.account].box[x.ref]
1862
1863                case Action.LOG:
1864                    assert x.ref in self.__vault.account[x.account].log
1865                    assert x.value is not None
1866                    if dry:
1867                        continue
1868                    if sub_positive_log_negative == -x.value:
1869                        self.__vault.account[x.account].count -= 1
1870                        sub_positive_log_negative = 0
1871                    box_ref = self.__vault.account[x.account].log[x.ref].ref
1872                    if not box_ref is None:
1873                        assert self.box_exists(x.account, box_ref)
1874                        box_value = self.__vault.account[x.account].log[x.ref].value
1875                        assert box_value < 0
1876
1877                        try:
1878                            self.__vault.account[x.account].box[box_ref].rest += -box_value
1879                        except TypeError:
1880                            self.__vault.account[x.account].box[box_ref].rest += decimal.Decimal(-box_value)
1881
1882                        try:
1883                            self.__vault.account[x.account].balance += -box_value
1884                        except TypeError:
1885                            self.__vault.account[x.account].balance += decimal.Decimal(-box_value)
1886
1887                        self.__vault.account[x.account].count -= 1
1888                    del self.__vault.account[x.account].log[x.ref]
1889
1890                case Action.SUBTRACT:
1891                    assert x.ref in self.__vault.account[x.account].box
1892                    assert x.value is not None
1893                    if dry:
1894                        continue
1895                    self.__vault.account[x.account].box[x.ref].rest += x.value
1896                    self.__vault.account[x.account].balance += x.value
1897                    sub_positive_log_negative = x.value
1898
1899                case Action.ADD_FILE:
1900                    assert x.ref in self.__vault.account[x.account].log
1901                    assert x.file is not None
1902                    assert dry or x.file in self.__vault.account[x.account].log[x.ref].file
1903                    if dry:
1904                        continue
1905                    del self.__vault.account[x.account].log[x.ref].file[x.file]
1906
1907                case Action.REMOVE_FILE:
1908                    assert x.ref in self.__vault.account[x.account].log
1909                    assert x.file is not None
1910                    assert x.value is not None
1911                    if dry:
1912                        continue
1913                    self.__vault.account[x.account].log[x.ref].file[x.file] = x.value
1914
1915                case Action.BOX_TRANSFER:
1916                    assert x.ref in self.__vault.account[x.account].box
1917                    assert x.value is not None
1918                    if dry:
1919                        continue
1920                    self.__vault.account[x.account].box[x.ref].rest -= x.value
1921
1922                case Action.EXCHANGE:
1923                    assert x.account in self.__vault.exchange
1924                    assert x.ref in self.__vault.exchange[x.account]
1925                    if dry:
1926                        continue
1927                    del self.__vault.exchange[x.account][x.ref]
1928
1929                case Action.REPORT:
1930                    assert x.ref in self.__vault.report
1931                    if dry:
1932                        continue
1933                    del self.__vault.report[x.ref]
1934
1935                case Action.ZAKAT:
1936                    assert x.ref in self.__vault.account[x.account].box
1937                    assert x.key is not None
1938                    assert hasattr(self.__vault.account[x.account].box[x.ref].zakat, x.key)
1939                    if dry:
1940                        continue
1941                    match x.math:
1942                        case MathOperation.ADDITION:
1943                            setattr(
1944                                self.__vault.account[x.account].box[x.ref].zakat,
1945                                x.key,
1946                                getattr(self.__vault.account[x.account].box[x.ref].zakat, x.key) - x.value,
1947                            )
1948                        case MathOperation.EQUAL:
1949                            setattr(
1950                                self.__vault.account[x.account].box[x.ref].zakat,
1951                                x.key,
1952                                x.value,
1953                            )
1954                        case MathOperation.SUBTRACTION:
1955                            setattr(
1956                                self.__vault.account[x.account].box[x.ref],
1957                                x.key,
1958                                getattr(self.__vault.account[x.account].box[x.ref], x.key) + x.value,
1959                            )
1960
1961        if not dry:
1962            del self.__vault.history[ref]
1963        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:
1965    def vault(self) -> dict:
1966        """
1967        Returns a copy of the internal vault dictionary.
1968
1969        This method is used to retrieve the current state of the ZakatTracker object.
1970        It provides a snapshot of the internal data structure, allowing for further
1971        processing or analysis.
1972
1973        Parameters:
1974        None
1975
1976        Returns:
1977        - dict: A copy of the internal vault dictionary.
1978        """
1979        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() -> FileStats:
1981    @staticmethod
1982    def stats_init() -> FileStats:
1983        """
1984        Initialize and return the initial file statistics.
1985
1986        Returns:
1987        - FileStats: A :class:`FileStats` instance with initial values
1988            of 0 bytes for both RAM and database.
1989        """
1990        return FileStats(
1991            database=SizeInfo(0, '0'),
1992            ram=SizeInfo(0, '0'),
1993        )

Initialize and return the initial file statistics.

Returns:

  • FileStats: A FileStats instance with initial values of 0 bytes for both RAM and database.
def stats(self, ignore_ram: bool = True) -> FileStats:
1995    def stats(self, ignore_ram: bool = True) -> FileStats:
1996        """
1997        Calculates and returns statistics about the object's data storage.
1998
1999        This method determines the size of the database file on disk and the
2000        size of the data currently held in RAM (likely within a dictionary).
2001        Both sizes are reported in bytes and in a human-readable format
2002        (e.g., KB, MB).
2003
2004        Parameters:
2005        - ignore_ram (bool, optional): Whether to ignore the RAM size. Default is True
2006
2007        Returns:
2008        - FileStats: A dataclass containing the following statistics:
2009
2010            * 'database': A tuple with two elements:
2011                - The database file size in bytes (float).
2012                - The database file size in human-readable format (str).
2013            * 'ram': A tuple with two elements:
2014                - The RAM usage (dictionary size) in bytes (float).
2015                - The RAM usage in human-readable format (str).
2016
2017        Example:
2018        ```bash
2019        >>> x = ZakatTracker()
2020        >>> stats = x.stats()
2021        >>> print(stats.database)
2022        SizeInfo(bytes=256000, human_readable='250.0 KB')
2023        >>> print(stats.ram)
2024        SizeInfo(bytes=12345, human_readable='12.1 KB')
2025        ```
2026        """
2027        ram_size = 0.0 if ignore_ram else self.get_dict_size(self.vault())
2028        file_size = os.path.getsize(self.path())
2029        return FileStats(
2030            database=SizeInfo(file_size, self.human_readable_size(file_size)),
2031            ram=SizeInfo(ram_size, self.human_readable_size(ram_size)),
2032        )

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:

  • FileStats: A dataclass 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)
SizeInfo(bytes=256000, human_readable='250.0 KB')
>>> print(stats.ram)
SizeInfo(bytes=12345, human_readable='12.1 KB')
def files(self) -> list[FileInfo]:
2034    def files(self) -> list[FileInfo]:
2035        """
2036        Retrieves information about files associated with this class.
2037
2038        This class method provides a standardized way to gather details about
2039        files used by the class for storage, snapshots, and CSV imports.
2040
2041        Parameters:
2042        None
2043
2044        Returns:
2045        - list[FileInfo]: A list of dataclass, each containing information
2046            about a specific file:
2047
2048            * type (str): The type of file ('database', 'snapshot', 'import_csv').
2049            * path (str): The full file path.
2050            * exists (bool): Whether the file exists on the filesystem.
2051            * size (int): The file size in bytes (0 if the file doesn't exist).
2052            * human_readable_size (str): A human-friendly representation of the file size (e.g., '10 KB', '2.5 MB').
2053        """
2054        result = []
2055        for file_type, path in {
2056            'database': self.path(),
2057            'snapshot': self.snapshot_cache_path(),
2058            'import_csv': self.import_csv_cache_path(),
2059        }.items():
2060            exists = os.path.exists(path)
2061            size = os.path.getsize(path) if exists else 0
2062            human_readable_size = self.human_readable_size(size) if exists else '0'
2063            result.append(FileInfo(
2064                type=file_type,
2065                path=path,
2066                exists=exists,
2067                size=size,
2068                human_readable_size=human_readable_size,
2069            ))
2070        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[FileInfo]: A list of dataclass, 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').
def account_exists(self, account: AccountID) -> bool:
2072    def account_exists(self, account: AccountID) -> bool:
2073        """
2074        Check if the given account exists in the vault.
2075
2076        Parameters:
2077        - account (AccountID): The account reference to check.
2078
2079        Returns:
2080        - bool: True if the account exists, False otherwise.
2081        """
2082        account = AccountID(account)
2083        return account in self.__vault.account

Check if the given account exists in the vault.

Parameters:

  • account (AccountID): The account reference to check.

Returns:

  • bool: True if the account exists, False otherwise.
def box_size(self, account: AccountID) -> int:
2085    def box_size(self, account: AccountID) -> int:
2086        """
2087        Calculate the size of the box for a specific account.
2088
2089        Parameters:
2090        - account (AccountID): The account reference for which the box size needs to be calculated.
2091
2092        Returns:
2093        - int: The size of the box for the given account. If the account does not exist, -1 is returned.
2094        """
2095        if self.account_exists(account):
2096            return len(self.__vault.account[account].box)
2097        return -1

Calculate the size of the box for a specific account.

Parameters:

  • account (AccountID): The account reference 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: AccountID) -> int:
2099    def log_size(self, account: AccountID) -> int:
2100        """
2101        Get the size of the log for a specific account.
2102
2103        Parameters:
2104        - account (AccountID): The account reference for which the log size needs to be calculated.
2105
2106        Returns:
2107        - int: The size of the log for the given account. If the account does not exist, -1 is returned.
2108        """
2109        if self.account_exists(account):
2110            return len(self.__vault.account[account].log)
2111        return -1

Get the size of the log for a specific account.

Parameters:

  • account (AccountID): The account reference 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:
2113    @staticmethod
2114    def hash_data(data: bytes, algorithm: str = 'blake2b') -> str:
2115        """
2116        Calculates the hash of given byte data using the specified algorithm.
2117
2118        Parameters:
2119        - data (bytes): The byte data to hash.
2120        - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'.
2121
2122        Returns:
2123        - str: The hexadecimal representation of the data's hash.
2124        """
2125        hash_obj = hashlib.new(algorithm)
2126        hash_obj.update(data)
2127        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:
2129    @staticmethod
2130    def hash_file(file_path: str, algorithm: str = 'blake2b') -> str:
2131        """
2132        Calculates the hash of a file using the specified algorithm.
2133
2134        Parameters:
2135        - file_path (str): The path to the file.
2136        - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'.
2137
2138        Returns:
2139        - str: The hexadecimal representation of the file's hash.
2140        """
2141        hash_obj = hashlib.new(algorithm)  # Create the hash object
2142        with open(file_path, 'rb') as file:  # Open file in binary mode for reading
2143            for chunk in iter(lambda: file.read(4096), b''):  # Read file in chunks
2144                hash_obj.update(chunk)
2145        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):
2147    def snapshot_cache_path(self):
2148        """
2149        Generate the path for the cache file used to store snapshots.
2150
2151        The cache file is a json file that stores the timestamps of the snapshots.
2152        The file name is derived from the main database file name by replacing the '.json' extension with '.snapshots.json'.
2153
2154        Parameters:
2155        None
2156
2157        Returns:
2158        - str: The path to the cache file.
2159        """
2160        path = str(self.path())
2161        ext = self.ext()
2162        ext_len = len(ext)
2163        if path.endswith(f'.{ext}'):
2164            path = path[:-ext_len - 1]
2165        _, filename = os.path.split(path + f'.snapshots.{ext}')
2166        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:
2168    def snapshot(self) -> bool:
2169        """
2170        This function creates a snapshot of the current database state.
2171
2172        The function calculates the hash of the current database file and checks if a snapshot with the same hash already exists.
2173        If a snapshot with the same hash exists, the function returns True without creating a new snapshot.
2174        If a snapshot with the same hash does not exist, the function creates a new snapshot by saving the current database state
2175        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.
2176
2177        Parameters:
2178        None
2179
2180        Returns:
2181        - bool: True if a snapshot with the same hash already exists or if the snapshot is successfully created. False if the snapshot creation fails.
2182        """
2183        current_hash = self.hash_file(self.path())
2184        cache: dict[str, int] = {}  # hash: time_ns
2185        try:
2186            with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream:
2187                cache = json.load(stream, cls=JSONDecoder)
2188        except:
2189            pass
2190        if current_hash in cache:
2191            return True
2192        ref = time.time_ns()
2193        cache[current_hash] = ref
2194        if not self.save(self.base_path('snapshots', f'{ref}.{self.ext()}')):
2195            return False
2196        with open(self.snapshot_cache_path(), 'w', encoding='utf-8') as stream:
2197            stream.write(json.dumps(cache, cls=JSONEncoder))
2198        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]]:
2200    def snapshots(self, hide_missing: bool = True, verified_hash_only: bool = False) \
2201            -> dict[int, tuple[str, str, bool]]:
2202        """
2203        Retrieve a dictionary of snapshots, with their respective hashes, paths, and existence status.
2204
2205        Parameters:
2206        - hide_missing (bool, optional): If True, only include snapshots that exist in the dictionary. Default is True.
2207        - verified_hash_only (bool, optional): If True, only include snapshots with a valid hash. Default is False.
2208
2209        Returns:
2210        - dict[int, tuple[str, str, bool]]: A dictionary where the keys are the timestamps of the snapshots,
2211        and the values are tuples containing the snapshot's hash, path, and existence status.
2212        """
2213        cache: dict[str, int] = {}  # hash: time_ns
2214        try:
2215            with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream:
2216                cache = json.load(stream, cls=JSONDecoder)
2217        except:
2218            pass
2219        if not cache:
2220            return {}
2221        result: dict[int, tuple[str, str, bool]] = {}  # time_ns: (hash, path, exists)
2222        for hash_file, ref in cache.items():
2223            path = self.base_path('snapshots', f'{ref}.{self.ext()}')
2224            exists = os.path.exists(path)
2225            valid_hash = self.hash_file(path) == hash_file if verified_hash_only else True
2226            if (verified_hash_only and not valid_hash) or (verified_hash_only and not exists):
2227                continue
2228            if exists or not hide_missing:
2229                result[ref] = (hash_file, path, exists)
2230        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: AccountID, ref_type: str, ref: Timestamp) -> bool:
2232    def ref_exists(self, account: AccountID, ref_type: str, ref: Timestamp) -> bool:
2233        """
2234        Check if a specific reference (transaction) exists in the vault for a given account and reference type.
2235
2236        Parameters:
2237        - account (AccountID): The account reference for which to check the existence of the reference.
2238        - ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
2239        - ref (Timestamp): The reference (transaction) number to check for existence.
2240
2241        Returns:
2242        - bool: True if the reference exists for the given account and reference type, False otherwise.
2243        """
2244        account = AccountID(account)
2245        if account in self.__vault.account:
2246            return ref in getattr(self.__vault.account[account], ref_type)
2247        return False

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

Parameters:

  • account (AccountID): The account reference 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: AccountID, ref: Timestamp) -> bool:
2249    def box_exists(self, account: AccountID, ref: Timestamp) -> bool:
2250        """
2251        Check if a specific box (transaction) exists in the vault for a given account and reference.
2252
2253        Parameters:
2254        - account (AccountID): The account reference for which to check the existence of the box.
2255        - ref (Timestamp): The reference (transaction) number to check for existence.
2256
2257        Returns:
2258        - bool: True if the box exists for the given account and reference, False otherwise.
2259        """
2260        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 (AccountID): The account reference 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: AccountID = '1', created_time_ns: Optional[Timestamp] = None, debug: bool = False) -> Optional[Timestamp]:
2262    def track(self, unscaled_value: float | int | decimal.Decimal = 0, desc: str = '', account: AccountID = AccountID('1'),
2263              created_time_ns: Optional[Timestamp] = None,
2264              debug: bool = False) -> Optional[Timestamp]:
2265        """
2266        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.
2267
2268        Parameters:
2269        - unscaled_value (float | int | decimal.Decimal, optional): The value of the transaction. Default is 0.
2270        - desc (str, optional): The description of the transaction. Default is an empty string.
2271        - account (AccountID, optional): The account reference for which the transaction is being tracked. Default is '1'.
2272        - 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.
2273        - debug (bool, optional): Whether to print debug information. Default is False.
2274
2275        Returns:
2276        - Optional[Timestamp]: The timestamp of the transaction in nanoseconds since epoch(1AD).
2277
2278        Raises:
2279        - ValueError: The created_time_ns should be greater than zero.
2280        - ValueError: The log transaction happened again in the same nanosecond time.
2281        - ValueError: The box transaction happened again in the same nanosecond time.
2282        """
2283        return self.__track(
2284            unscaled_value=unscaled_value,
2285            desc=desc,
2286            account=account,
2287            logging=True,
2288            created_time_ns=created_time_ns,
2289            debug=debug,
2290        )

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 (AccountID, optional): The account reference 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:

  • Optional[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: AccountID, ref: Timestamp) -> bool:
2358    def log_exists(self, account: AccountID, ref: Timestamp) -> bool:
2359        """
2360        Checks if a specific transaction log entry exists for a given account.
2361
2362        Parameters:
2363        - account (AccountID): The account reference associated with the transaction log.
2364        - ref (Timestamp): The reference to the transaction log entry.
2365
2366        Returns:
2367        - bool: True if the transaction log entry exists, False otherwise.
2368        """
2369        return self.ref_exists(account, 'log', ref)

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

Parameters:

  • account (AccountID): The account reference 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: AccountID, created_time_ns: Optional[Timestamp] = None, rate: Optional[float] = None, description: Optional[str] = None, debug: bool = False) -> Exchange:
2422    def exchange(self, account: AccountID, created_time_ns: Optional[Timestamp] = None,
2423                 rate: Optional[float] = None, description: Optional[str] = None, debug: bool = False) -> Exchange:
2424        """
2425        This method is used to record or retrieve exchange rates for a specific account.
2426
2427        Parameters:
2428        - account (AccountID): The account reference for which the exchange rate is being recorded or retrieved.
2429        - created_time_ns (Timestamp, optional): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
2430        - rate (float, optional): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
2431        - description (str, optional): A description of the exchange rate.
2432        - debug (bool, optional): Whether to print debug information. Default is False.
2433
2434        Returns:
2435        - Exchange: A dictionary containing the latest exchange rate and its description. If no exchange rate is found,
2436        it returns a dictionary with default values for the rate and description.
2437
2438        Raises:
2439        - ValueError: The created should be greater than zero.
2440        """
2441        if debug:
2442            print('exchange', f'debug={debug}')
2443        account = AccountID(account)
2444        if created_time_ns is None:
2445            created_time_ns = Time.time()
2446        if created_time_ns <= 0:
2447            raise ValueError('The created should be greater than zero.')
2448        if rate is not None:
2449            if rate <= 0:
2450                return Exchange()
2451            if account not in self.__vault.exchange:
2452                self.__vault.exchange[account] = {}
2453            if len(self.__vault.exchange[account]) == 0 and rate <= 1:
2454                return Exchange(time=created_time_ns, rate=1)
2455            no_lock = self.nolock()
2456            lock = self.__lock()
2457            self.__vault.exchange[account][created_time_ns] = Exchange(rate=rate, description=description)
2458            self.__step(Action.EXCHANGE, account, ref=created_time_ns, value=rate)
2459            if no_lock:
2460                assert lock is not None
2461                self.free(lock)
2462            if debug:
2463                print('exchange-created-1',
2464                      f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}')
2465
2466        if account in self.__vault.exchange:
2467            valid_rates = [(ts, r) for ts, r in self.__vault.exchange[account].items() if ts <= created_time_ns]
2468            if valid_rates:
2469                latest_rate = max(valid_rates, key=lambda x: x[0])
2470                if debug:
2471                    print('exchange-read-1',
2472                          f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}',
2473                          'latest_rate', latest_rate)
2474                result = latest_rate[1]
2475                result.time = latest_rate[0]
2476                return result  # إرجاع قاموس يحتوي على المعدل والوصف
2477        if debug:
2478            print('exchange-read-0', f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}')
2479        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 (AccountID): The account reference 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:
2481    @staticmethod
2482    def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
2483        """
2484        This function calculates the exchanged amount of a currency.
2485
2486        Parameters:
2487        - x (float): The original amount of the currency.
2488        - x_rate (float): The exchange rate of the original currency.
2489        - y_rate (float): The exchange rate of the target currency.
2490
2491        Returns:
2492        - float: The exchanged amount of the target currency.
2493        """
2494        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[AccountID, dict[Timestamp, Exchange]]:
2496    def exchanges(self) -> dict[AccountID, dict[Timestamp, Exchange]]:
2497        """
2498        Retrieve the recorded exchange rates for all accounts.
2499
2500        Parameters:
2501        None
2502
2503        Returns:
2504        - dict[AccountID, dict[Timestamp, Exchange]]: A dictionary containing all recorded exchange rates.
2505        The keys are account references or numbers, and the values are dictionaries containing the exchange rates.
2506        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
2507        """
2508        return self.__vault.exchange.copy()

Retrieve the recorded exchange rates for all accounts.

Parameters: None

Returns:

  • dict[AccountID, dict[Timestamp, Exchange]]: A dictionary containing all recorded exchange rates. The keys are account references 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[AccountID, AccountDetails]:
2510    def accounts(self) -> dict[AccountID, AccountDetails]:
2511        """
2512        Returns a dictionary containing account references as keys and their respective account details as values.
2513
2514        Parameters:
2515        None
2516
2517        Returns:
2518        - dict[AccountID, AccountDetails]: A dictionary where keys are account references and values are their respective details.
2519        """
2520        return {
2521            account_id: AccountDetails(
2522                account_id=account_id,
2523                account_name=self.__vault.account[account_id].name,
2524                balance=self.__vault.account[account_id].balance,
2525            )
2526            for account_id in self.__vault.account
2527        }

Returns a dictionary containing account references as keys and their respective account details as values.

Parameters: None

Returns:

  • dict[AccountID, AccountDetails]: A dictionary where keys are account references and values are their respective details.
def boxes( self, account: AccountID) -> dict[Timestamp, Box]:
2529    def boxes(self, account: AccountID) -> dict[Timestamp, Box]:
2530        """
2531        Retrieve the boxes (transactions) associated with a specific account.
2532
2533        Parameters:
2534        - account (AccountID): The account reference for which to retrieve the boxes.
2535
2536        Returns:
2537        - dict[Timestamp, Box]: A dictionary containing the boxes associated with the given account.
2538        If the account does not exist, an empty dictionary is returned.
2539        """
2540        if self.account_exists(account):
2541            return self.__vault.account[account].box
2542        return {}

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

Parameters:

  • account (AccountID): The account reference for which to retrieve the boxes.

Returns:

  • dict[Timestamp, Box]: 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: AccountID) -> dict[Timestamp, Log]:
2544    def logs(self, account: AccountID) -> dict[Timestamp, Log]:
2545        """
2546        Retrieve the logs (transactions) associated with a specific account.
2547
2548        Parameters:
2549        - account (AccountID): The account reference for which to retrieve the logs.
2550
2551        Returns:
2552        - dict[Timestamp, Log]: A dictionary containing the logs associated with the given account.
2553        If the account does not exist, an empty dictionary is returned.
2554        """
2555        if self.account_exists(account):
2556            return self.__vault.account[account].log
2557        return {}

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

Parameters:

  • account (AccountID): The account reference 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.
def timeline( self, weekday: WeekDay = <WeekDay.FRIDAY: 4>, debug: bool = False) -> Timeline:
2559    def timeline(self, weekday: WeekDay = WeekDay.FRIDAY, debug: bool = False) -> Timeline:
2560        """
2561        Aggregates transaction logs into a structured timeline.
2562
2563        This method retrieves transaction logs from all accounts and organizes them
2564        into daily, weekly, monthly, and yearly summaries. Each level of the
2565        timeline includes a `TimeSummary` object with the total positive, negative,
2566        and overall values for that period. The daily level also includes a list
2567        of individual `Transaction` records.
2568
2569        Parameters:
2570        - weekday (WeekDay, optional): The day of the week to use as the anchor
2571                for weekly summaries. Defaults to WeekDay.FRIDAY.
2572        - debug (bool, optional): If True, prints intermediate debug information
2573                during processing. Defaults to False.
2574
2575        Returns:
2576        - Timeline: An object containing the aggregated transaction data, organized
2577                into daily, weekly, monthly, and yearly summaries. The 'daily'
2578                attribute is a dictionary where keys are dates (YYYY-MM-DD) and
2579                values are `DailyRecords` objects. The 'weekly' attribute is a
2580                dictionary where keys are the starting datetime of the week and
2581                values are `TimeSummary` objects. The 'monthly' attribute is a
2582                dictionary where keys are year-month strings (YYYY-MM) and values
2583                are `TimeSummary` objects. The 'yearly' attribute is a dictionary
2584                where keys are years (YYYY) and values are `TimeSummary` objects.
2585
2586        Example:
2587        ```bash
2588        >>> from zakat import tracker
2589        >>> ledger = tracker(':memory:')
2590        >>> account1_id = ledger.create_account('account1')
2591        >>> account2_id = ledger.create_account('account2')
2592        >>> ledger.subtract(51, 'desc', account1_id)
2593        >>> ref = ledger.track(100, 'desc', account2_id)
2594        >>> ledger.add_file(account2_id, ref, 'file_0')
2595        >>> ledger.add_file(account2_id, ref, 'file_1')
2596        >>> ledger.add_file(account2_id, ref, 'file_2')
2597        >>> ledger.timeline()
2598        Timeline(
2599            daily={
2600                "2025-04-06": DailyRecords(
2601                    positive=10000,
2602                    negative=5100,
2603                    total=4900,
2604                    rows=[
2605                        Transaction(
2606                            account="account2",
2607                            account_id="63879638114290122752",
2608                            desc="desc2",
2609                            file={
2610                                63879638220705865728: "file_0",
2611                                63879638223391350784: "file_1",
2612                                63879638225766047744: "file_2",
2613                            },
2614                            value=10000,
2615                            time=63879638181936513024,
2616                            transfer=False,
2617                        ),
2618                        Transaction(
2619                            account="account1",
2620                            account_id="63879638104007106560",
2621                            desc="desc",
2622                            file={},
2623                            value=-5100,
2624                            time=63879638149199421440,
2625                            transfer=False,
2626                        ),
2627                    ],
2628                )
2629            },
2630            weekly={
2631                datetime.datetime(2025, 4, 2, 15, 56, 21): TimeSummary(
2632                    positive=10000, negative=0, total=10000
2633                ),
2634                datetime.datetime(2025, 4, 2, 15, 55, 49): TimeSummary(
2635                    positive=0, negative=5100, total=-5100
2636                ),
2637            },
2638            monthly={"2025-04": TimeSummary(positive=10000, negative=5100, total=4900)},
2639            yearly={2025: TimeSummary(positive=10000, negative=5100, total=4900)},
2640        )
2641        ```
2642        """
2643        logs: dict[Timestamp, list[Transaction]] = {}
2644        for account_id in self.accounts():
2645            for log_ref, log in self.logs(account_id).items():
2646                if log_ref not in logs:
2647                    logs[log_ref] = []
2648                logs[log_ref].append(Transaction(
2649                    account=self.name(account_id),
2650                    account_id=account_id,
2651                    desc=log.desc,
2652                    file=log.file,
2653                    value=log.value,
2654                    time=log_ref,
2655                    transfer=False,
2656                ))
2657        if debug:
2658            print('logs', logs)
2659        y = Timeline()
2660        for i in sorted(logs, reverse=True):
2661            dt = Time.time_to_datetime(i)
2662            daily = f'{dt.year}-{dt.month:02d}-{dt.day:02d}'
2663            weekly = dt - datetime.timedelta(days=weekday.value)
2664            monthly = f'{dt.year}-{dt.month:02d}'
2665            yearly = dt.year
2666            # daily
2667            if daily not in y.daily:
2668                y.daily[daily] = DailyRecords()
2669            transfer = len(logs[i]) > 1
2670            if debug:
2671                print('logs[i]', logs[i])
2672            for z in logs[i]:
2673                if debug:
2674                    print('z', z)
2675                # daily
2676                value = z.value
2677                if value > 0:
2678                    y.daily[daily].positive += value
2679                else:
2680                    y.daily[daily].negative += -value
2681                y.daily[daily].total += value
2682                z.transfer = transfer
2683                y.daily[daily].rows.append(z)
2684                # weekly
2685                if weekly not in y.weekly:
2686                    y.weekly[weekly] = TimeSummary()
2687                if value > 0:
2688                    y.weekly[weekly].positive += value
2689                else:
2690                    y.weekly[weekly].negative += -value
2691                y.weekly[weekly].total += value
2692                # monthly
2693                if monthly not in y.monthly:
2694                    y.monthly[monthly] = TimeSummary()
2695                if value > 0:
2696                    y.monthly[monthly].positive += value
2697                else:
2698                    y.monthly[monthly].negative += -value
2699                y.monthly[monthly].total += value
2700                # yearly
2701                if yearly not in y.yearly:
2702                    y.yearly[yearly] = TimeSummary()
2703                if value > 0:
2704                    y.yearly[yearly].positive += value
2705                else:
2706                    y.yearly[yearly].negative += -value
2707                y.yearly[yearly].total += value
2708        if debug:
2709            print('y', y)
2710        return y

Aggregates transaction logs into a structured timeline.

This method retrieves transaction logs from all accounts and organizes them into daily, weekly, monthly, and yearly summaries. Each level of the timeline includes a TimeSummary object with the total positive, negative, and overall values for that period. The daily level also includes a list of individual Transaction records.

Parameters:

  • weekday (WeekDay, optional): The day of the week to use as the anchor for weekly summaries. Defaults to WeekDay.FRIDAY.
  • debug (bool, optional): If True, prints intermediate debug information during processing. Defaults to False.

Returns:

  • Timeline: An object containing the aggregated transaction data, organized into daily, weekly, monthly, and yearly summaries. The 'daily' attribute is a dictionary where keys are dates (YYYY-MM-DD) and values are DailyRecords objects. The 'weekly' attribute is a dictionary where keys are the starting datetime of the week and values are TimeSummary objects. The 'monthly' attribute is a dictionary where keys are year-month strings (YYYY-MM) and values are TimeSummary objects. The 'yearly' attribute is a dictionary where keys are years (YYYY) and values are TimeSummary objects.

Example:

>>> from zakat import tracker
>>> ledger = tracker(':memory:')
>>> account1_id = ledger.create_account('account1')
>>> account2_id = ledger.create_account('account2')
>>> ledger.subtract(51, 'desc', account1_id)
>>> ref = ledger.track(100, 'desc', account2_id)
>>> ledger.add_file(account2_id, ref, 'file_0')
>>> ledger.add_file(account2_id, ref, 'file_1')
>>> ledger.add_file(account2_id, ref, 'file_2')
>>> ledger.timeline()
Timeline(
    daily={
        "2025-04-06": DailyRecords(
            positive=10000,
            negative=5100,
            total=4900,
            rows=[
                Transaction(
                    account="account2",
                    account_id="63879638114290122752",
                    desc="desc2",
                    file={
                        63879638220705865728: "file_0",
                        63879638223391350784: "file_1",
                        63879638225766047744: "file_2",
                    },
                    value=10000,
                    time=63879638181936513024,
                    transfer=False,
                ),
                Transaction(
                    account="account1",
                    account_id="63879638104007106560",
                    desc="desc",
                    file={},
                    value=-5100,
                    time=63879638149199421440,
                    transfer=False,
                ),
            ],
        )
    },
    weekly={
        datetime.datetime(2025, 4, 2, 15, 56, 21): TimeSummary(
            positive=10000, negative=0, total=10000
        ),
        datetime.datetime(2025, 4, 2, 15, 55, 49): TimeSummary(
            positive=0, negative=5100, total=-5100
        ),
    },
    monthly={"2025-04": TimeSummary(positive=10000, negative=5100, total=4900)},
    yearly={2025: TimeSummary(positive=10000, negative=5100, total=4900)},
)
def add_file( self, account: AccountID, ref: Timestamp, path: str) -> Timestamp:
2712    def add_file(self, account: AccountID, ref: Timestamp, path: str) -> Timestamp:
2713        """
2714        Adds a file reference to a specific transaction log entry in the vault.
2715
2716        Parameters:
2717        - account (AccountID): The account reference associated with the transaction log.
2718        - ref (Timestamp): The reference to the transaction log entry.
2719        - path (str): The path of the file to be added.
2720
2721        Returns:
2722        - Timestamp: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
2723        """
2724        if self.account_exists(account):
2725            if ref in self.__vault.account[account].log:
2726                no_lock = self.nolock()
2727                lock = self.__lock()
2728                file_ref = Time.time()
2729                self.__vault.account[account].log[ref].file[file_ref] = path
2730                self.__step(Action.ADD_FILE, account, ref=ref, file=file_ref)
2731                if no_lock:
2732                    assert lock is not None
2733                    self.free(lock)
2734                return file_ref
2735        return Timestamp(0)

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

Parameters:

  • account (AccountID): The account reference 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: AccountID, ref: Timestamp, file_ref: Timestamp) -> bool:
2737    def remove_file(self, account: AccountID, ref: Timestamp, file_ref: Timestamp) -> bool:
2738        """
2739        Removes a file reference from a specific transaction log entry in the vault.
2740
2741        Parameters:
2742        - account (AccountID): The account reference associated with the transaction log.
2743        - ref (Timestamp): The reference to the transaction log entry.
2744        - file_ref (Timestamp): The reference of the file to be removed.
2745
2746        Returns:
2747        - bool: True if the file reference is successfully removed, False otherwise.
2748        """
2749        if self.account_exists(account):
2750            if ref in self.__vault.account[account].log:
2751                if file_ref in self.__vault.account[account].log[ref].file:
2752                    no_lock = self.nolock()
2753                    lock = self.__lock()
2754                    x = self.__vault.account[account].log[ref].file[file_ref]
2755                    del self.__vault.account[account].log[ref].file[file_ref]
2756                    self.__step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x)
2757                    if no_lock:
2758                        assert lock is not None
2759                        self.free(lock)
2760                    return True
2761        return False

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

Parameters:

  • account (AccountID): The account reference 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: AccountID = '1', cached: bool = True) -> int:
2763    def balance(self, account: AccountID = AccountID('1'), cached: bool = True) -> int:
2764        """
2765        Calculate and return the balance of a specific account.
2766
2767        Parameters:
2768        - account (AccountID, optional): The account reference. Default is '1'.
2769        - cached (bool, optional): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
2770
2771        Returns:
2772        - int: The balance of the account.
2773
2774        Notes:
2775        - If cached is True, the function returns the cached balance.
2776        - If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
2777        """
2778        account = AccountID(account)
2779        if cached:
2780            return self.__vault.account[account].balance
2781        x = 0
2782        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 (AccountID, optional): The account reference. 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: AccountID, status: Optional[bool] = None) -> bool:
2784    def hide(self, account: AccountID, status: Optional[bool] = None) -> bool:
2785        """
2786        Check or set the hide status of a specific account.
2787
2788        Parameters:
2789        - account (AccountID): The account reference.
2790        - status (bool, optional): The new hide status. If not provided, the function will return the current status.
2791
2792        Returns:
2793        - bool: The current or updated hide status of the account.
2794
2795        Raises:
2796        None
2797
2798        Example:
2799        ```bash
2800        >>> tracker = ZakatTracker()
2801        >>> ref = tracker.track(51, 'desc', 'account1')
2802        >>> tracker.hide('account1')  # Set the hide status of 'account1' to True
2803        False
2804        >>> tracker.hide('account1', True)  # Set the hide status of 'account1' to True
2805        True
2806        >>> tracker.hide('account1')  # Get the hide status of 'account1' by default
2807        True
2808        >>> tracker.hide('account1', False)
2809        False
2810        ```
2811        """
2812        if self.account_exists(account):
2813            if status is None:
2814                return self.__vault.account[account].hide
2815            self.__vault.account[account].hide = status
2816            return status
2817        return False

Check or set the hide status of a specific account.

Parameters:

  • account (AccountID): The account reference.
  • 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 account( self, name: str, exact: bool = True) -> Optional[AccountDetails]:
2819    def account(self, name: str, exact: bool = True) -> Optional[AccountDetails]:
2820        """
2821        Retrieves an AccountDetails object for the first account matching the given name.
2822
2823        This method searches for accounts with names that contain the provided 'name'
2824        (case-insensitive substring matching). If a match is found, it returns an
2825        AccountDetails object containing the account's ID, name and balance. If no matching
2826        account is found, it returns None.
2827
2828        Parameters:
2829        - name: The name (or partial name) of the account to retrieve.
2830        - exact: If True, performs a case-insensitive exact match.
2831                 If False, performs a case-insensitive substring search.
2832                 Defaults to True.
2833
2834        Returns:
2835        - AccountDetails: An AccountDetails object representing the found account, or None if no
2836            matching account exists.
2837        """
2838        for account_name, account_id in self.names(name).items():
2839            if not exact or account_name.lower() == name.lower():
2840                return AccountDetails(
2841                    account_id=account_id,
2842                    account_name=account_name,
2843                    balance=self.__vault.account[account_id].balance,
2844                )
2845        return None

Retrieves an AccountDetails object for the first account matching the given name.

This method searches for accounts with names that contain the provided 'name' (case-insensitive substring matching). If a match is found, it returns an AccountDetails object containing the account's ID, name and balance. If no matching account is found, it returns None.

Parameters:

  • name: The name (or partial name) of the account to retrieve.
  • exact: If True, performs a case-insensitive exact match. If False, performs a case-insensitive substring search. Defaults to True.

Returns:

  • AccountDetails: An AccountDetails object representing the found account, or None if no matching account exists.
def create_account(self, name: str) -> AccountID:
2847    def create_account(self, name: str) -> AccountID:
2848        """
2849        Creates a new account with the given name and returns its unique ID.
2850
2851        This method:
2852        1. Checks if an account with the same name (case-insensitive) already exists.
2853        2. Generates a unique `AccountID` based on the current time.
2854        3. Tracks the account creation internally.
2855        4. Sets the account's name.
2856        5. Verifies that the name was set correctly.
2857    
2858        Parameters:
2859        - name: The name of the new account.
2860    
2861        Returns:
2862        - AccountID: The unique `AccountID` of the newly created account.
2863    
2864        Raises:
2865        - AssertionError: Empty account name is forbidden.
2866        - AssertionError: Account name in number is forbidden.
2867        - AssertionError: If an account with the same name already exists (case-insensitive).
2868        - AssertionError: If the provided name does not match the name set for the account.
2869        """
2870        assert name.strip(), 'empty account name is forbidden'
2871        assert not name.isdigit() and not name.isdecimal() and not name.isnumeric() and not is_number(name), f'Account name({name}) in number is forbidden'
2872        account_ref = self.account(name, exact=True)
2873        # check if account not exists
2874        assert account_ref is None, f'account name({name}) already used'
2875        # create new account
2876        account_id = AccountID(Time.time())
2877        self.__track(0, '', account_id)
2878        new_name = self.name(
2879            account=account_id,
2880            new_name=name,
2881        )
2882        assert name == new_name
2883        return account_id

Creates a new account with the given name and returns its unique ID.

This method:

  1. Checks if an account with the same name (case-insensitive) already exists.
  2. Generates a unique AccountID based on the current time.
  3. Tracks the account creation internally.
  4. Sets the account's name.
  5. Verifies that the name was set correctly.

Parameters:

  • name: The name of the new account.

Returns:

  • AccountID: The unique AccountID of the newly created account.

Raises:

  • AssertionError: Empty account name is forbidden.
  • AssertionError: Account name in number is forbidden.
  • AssertionError: If an account with the same name already exists (case-insensitive).
  • AssertionError: If the provided name does not match the name set for the account.
def names(self, keyword: str = '') -> dict[str, AccountID]:
2885    def names(self, keyword: str = '') -> dict[str, AccountID]:
2886        """
2887        Retrieves a dictionary of account IDs and names, optionally filtered by a keyword.
2888
2889        Parameters:
2890        - keyword: An optional string to filter account names. If provided, only accounts whose
2891            names contain the keyword (case-insensitive) will be included in the result.
2892            Defaults to an empty string, which returns all accounts.
2893
2894        Returns:
2895        - A dictionary where keys are account names and values are AccountIDs. The dictionary
2896            contains only accounts that match the provided keyword (if any).
2897        """
2898        return {
2899            account.name: account_id
2900            for account_id, account in self.__vault.account.items()
2901            if keyword.lower() in account.name.lower()
2902        }

Retrieves a dictionary of account IDs and names, optionally filtered by a keyword.

Parameters:

  • keyword: An optional string to filter account names. If provided, only accounts whose names contain the keyword (case-insensitive) will be included in the result. Defaults to an empty string, which returns all accounts.

Returns:

  • A dictionary where keys are account names and values are AccountIDs. The dictionary contains only accounts that match the provided keyword (if any).
def name( self, account: AccountID, new_name: Optional[str] = None) -> str:
2904    def name(self, account: AccountID, new_name: Optional[str] = None) -> str:
2905        """
2906        Retrieves or sets the name of an account.
2907
2908        Parameters:
2909        - account: The AccountID of the account.
2910        - new_name: The new name to set for the account. If None, the current name is retrieved.
2911
2912        Returns:
2913        - The current name of the account if `new_name` is None, or the `new_name` if it is set.
2914
2915        Note: Returns an empty string if the account does not exist.
2916        """
2917        if self.account_exists(account):
2918            if new_name is None:
2919                return self.__vault.account[account].name
2920            assert new_name != ''
2921            no_lock = self.nolock()
2922            lock = self.__lock()
2923            self.__step(Action.NAME, account, value=self.__vault.account[account].name)
2924            self.__vault.account[account].name = new_name
2925            if no_lock:
2926                    assert lock is not None
2927                    self.free(lock)
2928            return new_name
2929        return ''

Retrieves or sets the name of an account.

Parameters:

  • account: The AccountID of the account.
  • new_name: The new name to set for the account. If None, the current name is retrieved.

Returns:

  • The current name of the account if new_name is None, or the new_name if it is set.

Note: Returns an empty string if the account does not exist.

def zakatable( self, account: AccountID, status: Optional[bool] = None) -> bool:
2931    def zakatable(self, account: AccountID, status: Optional[bool] = None) -> bool:
2932        """
2933        Check or set the zakatable status of a specific account.
2934
2935        Parameters:
2936        - account (AccountID): The account reference.
2937        - status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
2938
2939        Returns:
2940        - bool: The current or updated zakatable status of the account.
2941
2942        Raises:
2943        None
2944
2945        Example:
2946        ```bash
2947        >>> tracker = ZakatTracker()
2948        >>> ref = tracker.track(51, 'desc', 'account1')
2949        >>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
2950        True
2951        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
2952        True
2953        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
2954        True
2955        >>> tracker.zakatable('account1', False)
2956        False
2957        ```
2958        """
2959        if self.account_exists(account):
2960            if status is None:
2961                return self.__vault.account[account].zakatable
2962            self.__vault.account[account].zakatable = status
2963            return status
2964        return False

Check or set the zakatable status of a specific account.

Parameters:

  • account (AccountID): The account reference.
  • 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: AccountID = '1', created_time_ns: Optional[Timestamp] = None, debug: bool = False) -> SubtractReport:
2966    def subtract(self, unscaled_value: float | int | decimal.Decimal, desc: str = '', account: AccountID = AccountID('1'),
2967            created_time_ns: Optional[Timestamp] = None,
2968            debug: bool = False) \
2969            -> SubtractReport:
2970        """
2971        Subtracts a specified value from an account's balance, if the amount to subtract is greater than the account's balance,
2972        the remaining amount will be transferred to a new transaction with a negative value.
2973
2974        Parameters:
2975        - unscaled_value (float | int | decimal.Decimal): The amount to be subtracted.
2976        - desc (str, optional): A description for the transaction. Defaults to an empty string.
2977        - account (AccountID, optional): The account reference from which the value will be subtracted. Defaults to '1'.
2978        - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD).
2979                                           If not provided, the current timestamp will be used.
2980        - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False.
2981
2982        Returns:
2983        - SubtractReport: A class containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
2984
2985        Raises:
2986        - ValueError: The unscaled_value should be greater than zero.
2987        - ValueError: The created_time_ns should be greater than zero.
2988        - ValueError: The box transaction happened again in the same nanosecond time.
2989        - ValueError: The log transaction happened again in the same nanosecond time.
2990        """
2991        if debug:
2992            print('sub', f'debug={debug}')
2993        account = AccountID(account)
2994        if unscaled_value <= 0:
2995            raise ValueError('The unscaled_value should be greater than zero.')
2996        if created_time_ns is None:
2997            created_time_ns = Time.time()
2998        if created_time_ns <= 0:
2999            raise ValueError('The created should be greater than zero.')
3000        no_lock = self.nolock()
3001        lock = self.__lock()
3002        self.__track(0, '', account)
3003        value = self.scale(unscaled_value)
3004        self.__log(value=-value, desc=desc, account=account, created_time_ns=created_time_ns, ref=None, debug=debug)
3005        ids = sorted(self.__vault.account[account].box.keys())
3006        limit = len(ids) + 1
3007        target = value
3008        if debug:
3009            print('ids', ids)
3010        ages = SubtractAges()
3011        for i in range(-1, -limit, -1):
3012            if target == 0:
3013                break
3014            j = ids[i]
3015            if debug:
3016                print('i', i, 'j', j)
3017            rest = self.__vault.account[account].box[j].rest
3018            if rest >= target:
3019                self.__vault.account[account].box[j].rest -= target
3020                self.__step(Action.SUBTRACT, account, ref=j, value=target)
3021                ages.append(SubtractAge(box_ref=j, total=target))
3022                target = 0
3023                break
3024            elif target > rest > 0:
3025                chunk = rest
3026                target -= chunk
3027                self.__vault.account[account].box[j].rest = 0
3028                self.__step(Action.SUBTRACT, account, ref=j, value=chunk)
3029                ages.append(SubtractAge(box_ref=j, total=chunk))
3030        if target > 0:
3031            self.__track(
3032                unscaled_value=self.unscale(-target),
3033                desc=desc,
3034                account=account,
3035                logging=False,
3036                created_time_ns=created_time_ns,
3037            )
3038            ages.append(SubtractAge(box_ref=created_time_ns, total=target))
3039        if no_lock:
3040            assert lock is not None
3041            self.free(lock)
3042        return SubtractReport(
3043            log_ref=created_time_ns,
3044            ages=ages,
3045        )

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 (AccountID, optional): The account reference 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: AccountID, to_account: AccountID, desc: str = '', created_time_ns: Optional[Timestamp] = None, debug: bool = False) -> Optional[TransferReport]:
3047    def transfer(self, unscaled_amount: float | int | decimal.Decimal, from_account: AccountID, to_account: AccountID, desc: str = '',
3048                 created_time_ns: Optional[Timestamp] = None,
3049                 debug: bool = False) -> Optional[TransferReport]:
3050        """
3051        Transfers a specified value from one account to another.
3052
3053        Parameters:
3054        - unscaled_amount (float | int | decimal.Decimal): The amount to be transferred.
3055        - from_account (AccountID): The account reference from which the value will be transferred.
3056        - to_account (AccountID): The account reference to which the value will be transferred.
3057        - desc (str, optional): A description for the transaction. Defaults to an empty string.
3058        - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, the current timestamp will be used.
3059        - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False.
3060
3061        Returns:
3062        - Optional[TransferReport]: A class of timestamps corresponding to the transactions made during the transfer.
3063
3064        Raises:
3065        - ValueError: Transfer to the same account is forbidden.
3066        - ValueError: The created_time_ns should be greater than zero.
3067        - ValueError: The box transaction happened again in the same nanosecond time.
3068        - ValueError: The log transaction happened again in the same nanosecond time.
3069        """
3070        if debug:
3071            print('transfer', f'debug={debug}')
3072        from_account = AccountID(from_account)
3073        to_account = AccountID(to_account)
3074        if from_account == to_account:
3075            raise ValueError(f'Transfer to the same account is forbidden. {to_account}')
3076        if unscaled_amount <= 0:
3077            return None
3078        if created_time_ns is None:
3079            created_time_ns = Time.time()
3080        if created_time_ns <= 0:
3081            raise ValueError('The created should be greater than zero.')
3082        no_lock = self.nolock()
3083        lock = self.__lock()
3084        subtract_report = self.subtract(unscaled_amount, desc, from_account, created_time_ns, debug=debug)
3085        source_exchange = self.exchange(from_account, created_time_ns)
3086        target_exchange = self.exchange(to_account, created_time_ns)
3087
3088        if debug:
3089            print('ages', subtract_report.ages)
3090
3091        transfer_report = TransferReport()
3092        for subtract in subtract_report.ages:
3093            times = TransferTimes()
3094            age = subtract.box_ref
3095            value = subtract.total
3096            assert source_exchange.rate is not None
3097            assert target_exchange.rate is not None
3098            target_amount = int(self.exchange_calc(value, source_exchange.rate, target_exchange.rate))
3099            if debug:
3100                print('target_amount', target_amount)
3101            # Perform the transfer
3102            if self.box_exists(to_account, age):
3103                if debug:
3104                    print('box_exists', age)
3105                capital = self.__vault.account[to_account].box[age].capital
3106                rest = self.__vault.account[to_account].box[age].rest
3107                if debug:
3108                    print(
3109                        f'Transfer(loop) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).')
3110                selected_age = age
3111                if rest + target_amount > capital:
3112                    self.__vault.account[to_account].box[age].capital += target_amount
3113                    selected_age = Time.time()
3114                self.__vault.account[to_account].box[age].rest += target_amount
3115                self.__step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount)
3116                y = self.__log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account,
3117                              created_time_ns=None, ref=None, debug=debug)
3118                times.append(TransferTime(box_ref=age, log_ref=y))
3119                continue
3120            if debug:
3121                print(
3122                    f'Transfer(func) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).')
3123            box_ref = self.__track(
3124                unscaled_value=self.unscale(int(target_amount)),
3125                desc=desc,
3126                account=to_account,
3127                logging=True,
3128                created_time_ns=age,
3129                debug=debug,
3130            )
3131            transfer_report.append(TransferRecord(
3132                box_ref=box_ref,
3133                times=times,
3134            ))
3135        if no_lock:
3136            assert lock is not None
3137            self.free(lock)
3138        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 (AccountID): The account reference from which the value will be transferred.
  • to_account (AccountID): The account reference 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:

  • Optional[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:
3140    def check(self,
3141              silver_gram_price: float,
3142              unscaled_nisab: Optional[float | int | decimal.Decimal] = None,
3143              debug: bool = False,
3144              created_time_ns: Optional[Timestamp] = None,
3145              cycle: Optional[float] = None) -> ZakatReport:
3146        """
3147        Check the eligibility for Zakat based on the given parameters.
3148
3149        Parameters:
3150        - silver_gram_price (float): The price of a gram of silver.
3151        - unscaled_nisab (float | int | decimal.Decimal, optional): The minimum amount of wealth required for Zakat.
3152                        If not provided, it will be calculated based on the silver_gram_price.
3153        - debug (bool, optional): Flag to enable debug mode.
3154        - created_time_ns (Timestamp, optional): The current timestamp. If not provided, it will be calculated using Time.time().
3155        - cycle (float, optional): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
3156
3157        Returns:
3158        - ZakatReport: A tuple containing a boolean indicating the eligibility for Zakat,
3159            a list of brief statistics, and a dictionary containing the Zakat plan.
3160        """
3161        if debug:
3162            print('check', f'debug={debug}')
3163        before_parameters = {
3164            "silver_gram_price": silver_gram_price,
3165            "unscaled_nisab": unscaled_nisab,
3166            "debug": debug,
3167            "created_time_ns": created_time_ns,
3168            "cycle": cycle,
3169        }
3170        if created_time_ns is None:
3171            created_time_ns = Time.time()
3172        if cycle is None:
3173            cycle = ZakatTracker.TimeCycle()
3174        if unscaled_nisab is None:
3175            unscaled_nisab = ZakatTracker.Nisab(silver_gram_price)
3176        nisab = self.scale(unscaled_nisab)
3177        plan: dict[AccountID, list[BoxPlan]] = {}
3178        summary = ZakatSummary()
3179        below_nisab = 0
3180        valid = False
3181        after_parameters = {
3182            "silver_gram_price": silver_gram_price,
3183            "unscaled_nisab": unscaled_nisab,
3184            "debug": debug,
3185            "created_time_ns": created_time_ns,
3186            "cycle": cycle,
3187        }
3188        if debug:
3189            print('exchanges', self.exchanges())
3190        for x in self.__vault.account:
3191            if not self.zakatable(x):
3192                continue
3193            _box = self.__vault.account[x].box
3194            _log = self.__vault.account[x].log
3195            limit = len(_box) + 1
3196            ids = sorted(self.__vault.account[x].box.keys())
3197            for i in range(-1, -limit, -1):
3198                j = ids[i]
3199                rest = float(_box[j].rest)
3200                if rest <= 0:
3201                    continue
3202                exchange = self.exchange(x, created_time_ns=Time.time())
3203                assert exchange.rate is not None
3204                rest = ZakatTracker.exchange_calc(rest, float(exchange.rate), 1)
3205                summary.num_wealth_items += 1
3206                summary.total_wealth += rest
3207                epoch = (created_time_ns - j) / cycle
3208                if debug:
3209                    print(f'Epoch: {epoch}', _box[j])
3210                if _box[j].zakat.last > 0:
3211                    epoch = (created_time_ns - _box[j].zakat.last) / cycle
3212                if debug:
3213                    print(f'Epoch: {epoch}')
3214                epoch = math.floor(epoch)
3215                if debug:
3216                    print(f'Epoch: {epoch}', type(epoch), epoch == 0, 1 - epoch, epoch)
3217                if epoch == 0:
3218                    continue
3219                if debug:
3220                    print('Epoch - PASSED')
3221                summary.num_zakatable_items += 1
3222                summary.total_zakatable_amount += rest
3223                is_nisab = rest >= nisab
3224                total = 0
3225                if is_nisab:
3226                    for _ in range(epoch):
3227                        total += ZakatTracker.ZakatCut(float(rest) - float(total))
3228                    valid = total > 0
3229                elif rest > 0:
3230                    below_nisab += rest
3231                    total = ZakatTracker.ZakatCut(float(rest))
3232                if total > 0:
3233                    if x not in plan:
3234                        plan[x] = []
3235                    summary.total_zakat_due += total
3236                    plan[x].append(BoxPlan(
3237                        below_nisab=not is_nisab,
3238                        total=total,
3239                        count=epoch,
3240                        ref=j,
3241                        box=_box[j],
3242                        log=_log[j],
3243                        exchange=exchange,
3244                    ))
3245        valid = valid or below_nisab >= nisab
3246        if debug:
3247            print(f'below_nisab({below_nisab}) >= nisab({nisab})')
3248        report = ZakatReport(
3249            created=Time.time(),
3250            valid=valid,
3251            summary=summary,
3252            plan=plan,
3253            parameters={
3254                'before': before_parameters,
3255                'after': after_parameters,
3256            },
3257        )
3258        self.__vault.cache.zakat = report if valid else None
3259        return report

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:
3261    def build_payment_parts(self, scaled_demand: int, positive_only: bool = True) -> PaymentParts:
3262        """
3263        Build payment parts for the Zakat distribution.
3264
3265        Parameters:
3266        - scaled_demand (int): The total demand for payment in local currency.
3267        - positive_only (bool, optional): If True, only consider accounts with positive balance. Default is True.
3268
3269        Returns:
3270        - PaymentParts: A dictionary containing the payment parts for each account. The dictionary has the following structure:
3271        {
3272            'account': {
3273                'account_id': {'balance': float, 'rate': float, 'part': float},
3274                ...
3275            },
3276            'exceed': bool,
3277            'demand': int,
3278            'total': float,
3279        }
3280        """
3281        total = 0.0
3282        parts = PaymentParts(
3283            account={},
3284            exceed=False,
3285            demand=int(round(scaled_demand)),
3286            total=0,
3287        )
3288        for x, y in self.accounts().items():
3289            if positive_only and y.balance <= 0:
3290                continue
3291            total += float(y.balance)
3292            exchange = self.exchange(x)
3293            parts.account[x] = AccountPaymentPart(balance=y.balance, rate=exchange.rate, part=0)
3294        parts.total = total
3295        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:
3297    @staticmethod
3298    def check_payment_parts(parts: PaymentParts, debug: bool = False) -> int:
3299        """
3300        Checks the validity of payment parts.
3301
3302        Parameters:
3303        - parts (dict[str, PaymentParts): A dictionary containing payment parts information.
3304        - debug (bool, optional): Flag to enable debug mode.
3305
3306        Returns:
3307        - int: Returns 0 if the payment parts are valid, otherwise returns the error code.
3308
3309        Error Codes:
3310        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
3311        2: 'balance', 'rate' or 'part' key is missing in parts['account'][x].
3312        3: 'part' value in parts['account'][x] is less than 0.
3313        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
3314        5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
3315        6: The sum of 'part' values in parts['account'] does not match with 'demand' value.
3316        """
3317        if debug:
3318            print('check_payment_parts', f'debug={debug}')
3319        # for i in ['demand', 'account', 'total', 'exceed']:
3320        #     if i not in parts:
3321        #         return 1
3322        exceed = parts.exceed
3323        # for j in ['balance', 'rate', 'part']:
3324        #     if j not in parts.account[x]:
3325        #         return 2
3326        for x in parts.account:
3327            if parts.account[x].part < 0:
3328                return 3
3329            if not exceed and parts.account[x].balance <= 0:
3330                return 4
3331        demand = parts.demand
3332        z = 0.0
3333        for _, y in parts.account.items():
3334            if not exceed and y.part > y.balance:
3335                return 5
3336            z += ZakatTracker.exchange_calc(y.part, y.rate, 1.0)
3337        z = round(z, 2)
3338        demand = round(demand, 2)
3339        if debug:
3340            print('check_payment_parts', f'z = {z}, demand = {demand}')
3341            print('check_payment_parts', type(z), type(demand))
3342            print('check_payment_parts', z != demand)
3343            print('check_payment_parts', str(z) != str(demand))
3344        if z != demand and str(z) != str(demand):
3345            return 6
3346        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:
3348    def zakat(self, report: ZakatReport,
3349        parts: Optional[PaymentParts] = None, debug: bool = False) -> bool:
3350        """
3351        Perform Zakat calculation based on the given report and optional parts.
3352
3353        Parameters:
3354        - report (ZakatReport): A dataclass containing the validity of the report, the report data, and the zakat plan.
3355        - parts (PaymentParts, optional): A dictionary containing the payment parts for the zakat.
3356        - debug (bool, optional): A flag indicating whether to print debug information.
3357
3358        Returns:
3359        - bool: True if the zakat calculation is successful, False otherwise.
3360
3361        Raises:
3362        - AssertionError: Bad Zakat report, call `check` first then call `zakat`.
3363        """
3364        if debug:
3365            print('zakat', f'debug={debug}')
3366        if not report.valid:
3367            return report.valid
3368        assert report.plan
3369        parts_exist = parts is not None
3370        if parts_exist:
3371            if self.check_payment_parts(parts, debug=debug) != 0:
3372                return False
3373        if debug:
3374            print('######### zakat #######')
3375            print('parts_exist', parts_exist)
3376        assert report == self.__vault.cache.zakat, "Bad Zakat report, call `check` first then call `zakat`"
3377        no_lock = self.nolock()
3378        lock = self.__lock()
3379        report_time = Time.time()
3380        self.__vault.report[report_time] = report
3381        self.__step(Action.REPORT, ref=report_time)
3382        created_time_ns = Time.time()
3383        for x in report.plan:
3384            target_exchange = self.exchange(x)
3385            if debug:
3386                print(report.plan[x])
3387                print('-------------')
3388                print(self.__vault.account[x].box)
3389            if debug:
3390                print('plan[x]', report.plan[x])
3391            for plan in report.plan[x]:
3392                j = plan.ref
3393                if debug:
3394                    print('j', j)
3395                assert j
3396                self.__step(Action.ZAKAT, account=x, ref=j, value=self.__vault.account[x].box[j].zakat.last,
3397                           key='last',
3398                           math_operation=MathOperation.EQUAL)
3399                self.__vault.account[x].box[j].zakat.last = created_time_ns
3400                assert target_exchange.rate is not None
3401                amount = ZakatTracker.exchange_calc(float(plan.total), 1, float(target_exchange.rate))
3402                self.__vault.account[x].box[j].zakat.total += amount
3403                self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='total',
3404                           math_operation=MathOperation.ADDITION)
3405                self.__vault.account[x].box[j].zakat.count += plan.count
3406                self.__step(Action.ZAKAT, account=x, ref=j, value=plan.count, key='count',
3407                           math_operation=MathOperation.ADDITION)
3408                if not parts_exist:
3409                    try:
3410                        self.__vault.account[x].box[j].rest -= amount
3411                    except TypeError:
3412                        self.__vault.account[x].box[j].rest -= decimal.Decimal(amount)
3413                    # self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest',
3414                    #            math_operation=MathOperation.SUBTRACTION)
3415                    self.__log(-float(amount), desc='zakat-زكاة', account=x, created_time_ns=None, ref=j, debug=debug)
3416        if parts_exist:
3417            for account, part in parts.account.items():
3418                if part.part == 0:
3419                    continue
3420                if debug:
3421                    print('zakat-part', account, part.rate)
3422                target_exchange = self.exchange(account)
3423                assert target_exchange.rate is not None
3424                amount = ZakatTracker.exchange_calc(part.part, part.rate, target_exchange.rate)
3425                unscaled_amount = self.unscale(int(amount))
3426                if unscaled_amount <= 0:
3427                    if debug:
3428                        print(f"The amount({unscaled_amount:.20f}) it was {amount:.20f} should be greater tha zero.")
3429                    continue
3430                self.subtract(
3431                    unscaled_value=unscaled_amount,
3432                    desc='zakat-part-دفعة-زكاة',
3433                    account=account,
3434                    debug=debug,
3435                )
3436        if no_lock:
3437            assert lock is not None
3438            self.free(lock)
3439        self.__vault.cache.zakat = None
3440        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.

Raises:

  • AssertionError: Bad Zakat report, call check first then call zakat.
@staticmethod
def split_at_last_symbol(data: str, symbol: str) -> tuple[str, str]:
3442    @staticmethod
3443    def split_at_last_symbol(data: str, symbol: str) -> tuple[str, str]:
3444        """Splits a string at the last occurrence of a given symbol.
3445    
3446        Parameters:
3447        - data (str): The input string.
3448        - symbol (str): The symbol to split at.
3449    
3450        Returns:
3451        - tuple[str, str]: A tuple containing two strings, the part before the last symbol and
3452            the part after the last symbol. If the symbol is not found, returns (data, "").
3453        """
3454        last_symbol_index = data.rfind(symbol)
3455    
3456        if last_symbol_index != -1:
3457            before_symbol = data[:last_symbol_index]
3458            after_symbol = data[last_symbol_index + len(symbol):]
3459            return before_symbol, after_symbol
3460        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:
3462    def save(self, path: Optional[str] = None, hash_required: bool = True) -> bool:
3463        """
3464        Saves the ZakatTracker's current state to a json file.
3465
3466        This method serializes the internal data (`__vault`).
3467
3468        Parameters:
3469        - path (str, optional): File path for saving. Defaults to a predefined location.
3470        - hash_required (bool, optional): Whether to add the data integrity using a hash. Defaults to True.
3471
3472        Returns:
3473        - bool: True if the save operation is successful, False otherwise.
3474        """
3475        if path is None:
3476            path = self.path()
3477        # first save in tmp file
3478        temp = f'{path}.tmp'
3479        try:
3480            with open(temp, 'w', encoding='utf-8') as stream:
3481                data = json.dumps(self.__vault, cls=JSONEncoder)
3482                stream.write(data)
3483                if hash_required:
3484                    hashed = self.hash_data(data.encode())
3485                    stream.write(f'//{hashed}')
3486            # then move tmp file to original location
3487            shutil.move(temp, path)
3488            return True
3489        except (IOError, OSError) as e:
3490            print(f'Error saving file: {e}')
3491            if os.path.exists(temp):
3492                os.remove(temp)
3493            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:
3495    @staticmethod
3496    def load_vault_from_json(json_string: str) -> Vault:
3497        """Loads a Vault dataclass from a JSON string."""
3498        data = json.loads(json_string)
3499
3500        vault = Vault()
3501
3502        # Load Accounts
3503        for account_reference, account_data in data.get("account", {}).items():
3504            account_reference = AccountID(account_reference)
3505            box_data = account_data.get('box', {})
3506            box = {
3507                Timestamp(ts): Box(
3508                    capital=box_data[str(ts)]["capital"],
3509                    rest=box_data[str(ts)]["rest"],
3510                    zakat=BoxZakat(**box_data[str(ts)]["zakat"]),
3511                )
3512                for ts in box_data
3513            }
3514
3515            log_data = account_data.get('log', {})
3516            log = {Timestamp(ts): Log(
3517                value=log_data[str(ts)]['value'],
3518                desc=log_data[str(ts)]['desc'],
3519                ref=Timestamp(log_data[str(ts)].get('ref')) if log_data[str(ts)].get('ref') is not None else None,
3520                file={Timestamp(ft): fv for ft, fv in log_data[str(ts)].get('file', {}).items()},
3521            ) for ts in log_data}
3522
3523            vault.account[account_reference] = Account(
3524                balance=account_data["balance"],
3525                created=Timestamp(account_data["created"]),
3526                name=account_data.get("name", ""),
3527                box=box,
3528                count=account_data.get("count", 0),
3529                log=log,
3530                hide=account_data.get("hide", False),
3531                zakatable=account_data.get("zakatable", True),
3532            )
3533
3534        # Load Exchanges
3535        for account_reference, exchange_data in data.get("exchange", {}).items():
3536            account_reference = AccountID(account_reference)
3537            vault.exchange[account_reference] = {}
3538            for timestamp, exchange_details in exchange_data.items():
3539                vault.exchange[account_reference][Timestamp(timestamp)] = Exchange(
3540                    rate=exchange_details.get("rate"),
3541                    description=exchange_details.get("description"),
3542                    time=Timestamp(exchange_details.get("time")) if exchange_details.get("time") is not None else None,
3543                )
3544
3545        # Load History
3546        for timestamp, history_dict in data.get("history", {}).items():
3547            vault.history[Timestamp(timestamp)] = {}
3548            for history_key, history_data in history_dict.items():
3549                vault.history[Timestamp(timestamp)][Timestamp(history_key)] = History(
3550                    action=Action(history_data["action"]),
3551                    account=AccountID(history_data["account"]) if history_data.get("account") is not None else None,
3552                    ref=Timestamp(history_data.get("ref")) if history_data.get("ref") is not None else None,
3553                    file=Timestamp(history_data.get("file")) if history_data.get("file") is not None else None,
3554                    key=history_data.get("key"),
3555                    value=history_data.get("value"),
3556                    math=MathOperation(history_data.get("math")) if history_data.get("math") is not None else None,
3557                )
3558
3559        # Load Lock
3560        vault.lock = Timestamp(data["lock"]) if data.get("lock") is not None else None
3561
3562        # Load Report
3563        for timestamp, report_data in data.get("report", {}).items():
3564            zakat_plan: dict[AccountID, list[BoxPlan]] = {}
3565            for account_reference, box_plans in report_data.get("plan", {}).items():
3566                account_reference = AccountID(account_reference)
3567                zakat_plan[account_reference] = []
3568                for box_plan_data in box_plans:
3569                    zakat_plan[account_reference].append(BoxPlan(
3570                        box=Box(
3571                            capital=box_plan_data["box"]["capital"],
3572                            rest=box_plan_data["box"]["rest"],
3573                            zakat=BoxZakat(**box_plan_data["box"]["zakat"]),
3574                        ),
3575                        log=Log(**box_plan_data["log"]),
3576                        exchange=Exchange(**box_plan_data["exchange"]),
3577                        below_nisab=box_plan_data["below_nisab"],
3578                        total=box_plan_data["total"],
3579                        count=box_plan_data["count"],
3580                        ref=Timestamp(box_plan_data["ref"]),
3581                    ))
3582
3583            vault.report[Timestamp(timestamp)] = ZakatReport(
3584                created=report_data["created"],
3585                valid=report_data["valid"],
3586                summary=ZakatSummary(**report_data["summary"]),
3587                plan=zakat_plan,
3588                parameters=report_data["parameters"],
3589            )
3590
3591        # Load Cache
3592        vault.cache = Cache()
3593        cache_data = data.get("cache", {})
3594        if "zakat" in cache_data:
3595            cache_zakat_data = cache_data.get("zakat", {})
3596            if cache_zakat_data:
3597                zakat_plan: dict[AccountID, list[BoxPlan]] = {}
3598                for account_reference, box_plans in cache_zakat_data.get("plan", {}).items():
3599                    account_reference = AccountID(account_reference)
3600                    zakat_plan[account_reference] = []
3601                    for box_plan_data in box_plans:
3602                        zakat_plan[account_reference].append(BoxPlan(
3603                            box=Box(
3604                                capital=box_plan_data["box"]["capital"],
3605                                rest=box_plan_data["box"]["rest"],
3606                                zakat=BoxZakat(**box_plan_data["box"]["zakat"]),
3607                            ),
3608                            log=Log(**box_plan_data["log"]),
3609                            exchange=Exchange(**box_plan_data["exchange"]),
3610                            below_nisab=box_plan_data["below_nisab"],
3611                            total=box_plan_data["total"],
3612                            count=box_plan_data["count"],
3613                            ref=Timestamp(box_plan_data["ref"]),
3614                        ))
3615
3616                vault.cache.zakat = ZakatReport(
3617                    created=cache_zakat_data["created"],
3618                    valid=cache_zakat_data["valid"],
3619                    summary=ZakatSummary(**cache_zakat_data["summary"]),
3620                    plan=zakat_plan,
3621                    parameters=cache_zakat_data["parameters"],
3622                )
3623
3624        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:
3626    def load(self, path: Optional[str] = None, hash_required: bool = True, debug: bool = False) -> bool:
3627        """
3628        Load the current state of the ZakatTracker object from a json file.
3629
3630        Parameters:
3631        - path (str, optional): The path where the json file is located. If not provided, it will use the default path.
3632        - hash_required (bool, optional): Whether to verify the data integrity using a hash. Defaults to True.
3633        - debug (bool, optional): Flag to enable debug mode.
3634
3635        Returns:
3636        - bool: True if the load operation is successful, False otherwise.
3637        """
3638        if path is None:
3639            path = self.path()
3640        try:
3641            if os.path.exists(path):
3642                with open(path, 'r', encoding='utf-8') as stream:
3643                    file = stream.read()
3644                    data, hashed = self.split_at_last_symbol(file, '//')
3645                    if hash_required:
3646                        assert hashed
3647                        if debug:
3648                            print('[debug-load]', hashed)
3649                        new_hash = self.hash_data(data.encode())
3650                        if debug:
3651                            print('[debug-load]', new_hash)
3652                        assert hashed == new_hash, "Hash verification failed. File may be corrupted."
3653                    self.__vault = self.load_vault_from_json(data)
3654                return True
3655            else:
3656                print(f'File not found: {path}')
3657                return False
3658        except (IOError, OSError) as e:
3659            print(f'Error loading file: {e}')
3660            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):
3662    def import_csv_cache_path(self):
3663        """
3664        Generates the cache file path for imported CSV data.
3665
3666        This function constructs the file path where cached data from CSV imports
3667        will be stored. The cache file is a json file (.json extension) appended
3668        to the base path of the object.
3669
3670        Parameters:
3671        None
3672
3673        Returns:
3674        - str: The full path to the import CSV cache file.
3675
3676        Example:
3677        ```bash
3678        >>> obj = ZakatTracker('/data/reports')
3679        >>> obj.import_csv_cache_path()
3680        '/data/reports.import_csv.json'
3681        ```
3682        """
3683        path = str(self.path())
3684        ext = self.ext()
3685        ext_len = len(ext)
3686        if path.endswith(f'.{ext}'):
3687            path = path[:-ext_len - 1]
3688        _, filename = os.path.split(path + f'.import_csv.{ext}')
3689        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'
@staticmethod
def get_transaction_csv_headers() -> list[str]:
3691    @staticmethod
3692    def get_transaction_csv_headers() -> list[str]:
3693        """
3694        Returns a list of strings representing the headers for a transaction CSV file.
3695
3696        The headers include:
3697        - account: The account associated with the transaction.
3698        - desc: A description of the transaction.
3699        - value: The monetary value of the transaction.
3700        - date: The date of the transaction.
3701        - rate: The applicable rate (if any) for the transaction.
3702        - reference: An optional reference number or identifier for the transaction.
3703
3704        Returns:
3705        - list[str]: A list containing the CSV header strings.
3706        """
3707        return [
3708            "account",
3709            "desc",
3710            "value",
3711            "date",
3712            "rate",
3713            "reference",
3714        ]

Returns a list of strings representing the headers for a transaction CSV file.

The headers include:

  • account: The account associated with the transaction.
  • desc: A description of the transaction.
  • value: The monetary value of the transaction.
  • date: The date of the transaction.
  • rate: The applicable rate (if any) for the transaction.
  • reference: An optional reference number or identifier for the transaction.

Returns:

  • list[str]: A list containing the CSV header strings.
def import_csv( self, path: str = 'file.csv', scale_decimal_places: int = 0, delimiter: str = ',', debug: bool = False) -> ImportReport:
3716    def import_csv(self, path: str = 'file.csv', scale_decimal_places: int = 0, delimiter: str = ',', debug: bool = False) -> ImportReport:
3717        """
3718        The function reads the CSV file, checks for duplicate transactions and tries it's best to creates the transactions history accordingly in the system.
3719
3720        Parameters:
3721        - path (str, optional): The path to the CSV file. Default is 'file.csv'.
3722        - scale_decimal_places (int, optional): The number of decimal places to scale the value. Default is 0.
3723        - delimiter (str, optional): The delimiter character to use in the CSV file. Defaults to ','.
3724        - debug (bool, optional): A flag indicating whether to print debug information.
3725
3726        Returns:
3727        - ImportReport: A dataclass containing the number of transactions created, the number of transactions found in the cache,
3728                and a dictionary of bad transactions.
3729
3730        Notes:
3731        * Currency Pair Assumption: This function assumes that the exchange rates stored for each account
3732                                    are appropriate for the currency pairs involved in the conversions.
3733        * The exchange rate for each account is based on the last encountered transaction rate that is not equal
3734            to 1.0 or the previous rate for that account.
3735        * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent
3736            transactions of the same account within the whole imported and existing dataset when doing `transfer`, `check` and
3737            `zakat` operations.
3738
3739        Example:
3740            The CSV file should have the following format, rate and reference are optionals per transaction:
3741            account, desc, value, date, rate, reference
3742            For example:
3743            safe-45, 'Some text', 34872, 1988-06-30 00:00:00.000000, 1, 6554
3744        """
3745        if debug:
3746            print('import_csv', f'debug={debug}')
3747        cache: list[int] = []
3748        try:
3749            if not self.memory_mode():
3750                with open(self.import_csv_cache_path(), 'r', encoding='utf-8') as stream:
3751                    cache = json.load(stream)
3752        except Exception as e:
3753            if debug:
3754                print(e)
3755        date_formats = [
3756            '%Y-%m-%d %H:%M:%S.%f',
3757            '%Y-%m-%dT%H:%M:%S.%f',
3758            '%Y-%m-%dT%H%M%S.%f',
3759            '%Y-%m-%d',
3760        ]
3761        statistics = ImportStatistics(0, 0, 0)
3762        data: dict[int, list[CSVRecord]] = {}
3763        with open(path, newline='', encoding='utf-8') as f:
3764            i = 0
3765            for row in csv.reader(f, delimiter=delimiter):
3766                if debug:
3767                    print(f"csv_row({i})", row, type(row))
3768                if row == self.get_transaction_csv_headers():
3769                    continue
3770                i += 1
3771                hashed = hash(tuple(row))
3772                if hashed in cache:
3773                    statistics.found += 1
3774                    continue
3775                account = row[0]
3776                desc = row[1]
3777                value = float(row[2])
3778                rate = 1.0
3779                reference = ''
3780                if row[4:5]: # Empty list if index is out of range
3781                    rate = float(row[4])
3782                if row[5:6]:
3783                    reference = row[5]
3784                date: int = 0
3785                for time_format in date_formats:
3786                    try:
3787                        date_str = row[3]
3788                        if "." not in date_str:
3789                            date_str += ".000000"
3790                        date = Time.time(datetime.datetime.strptime(date_str, time_format))
3791                        break
3792                    except Exception as e:
3793                        if debug:
3794                            print(e)
3795                record = CSVRecord(
3796                    index=i,
3797                    account=account,
3798                    desc=desc,
3799                    value=value,
3800                    date=date,
3801                    rate=rate,
3802                    reference=reference,
3803                    hashed=hashed,
3804                    error='',
3805                )
3806                if date <= 0:
3807                    record.error = 'invalid date'
3808                    statistics.bad += 1
3809                if value == 0:
3810                    record.error = 'invalid value'
3811                    statistics.bad += 1
3812                    continue
3813                if date not in data:
3814                    data[date] = []
3815                data[date].append(record)
3816
3817        if debug:
3818            print('import_csv', len(data))
3819
3820        if statistics.bad > 0:
3821            return ImportReport(
3822                statistics=statistics,
3823                bad=[
3824                    item
3825                    for sublist in data.values()
3826                    for item in sublist
3827                    if item.error
3828                ],
3829            )
3830
3831        no_lock = self.nolock()
3832        lock = self.__lock()
3833        names = self.names()
3834
3835        # sync accounts
3836        if debug:
3837            print('before-names', names, len(names))
3838        for date, rows in sorted(data.items()):
3839            new_rows: list[CSVRecord] = []
3840            for row in rows:
3841                if row.account not in names:
3842                    account_id = self.create_account(row.account)
3843                    names[row.account] = account_id
3844                account_id = names[row.account]
3845                assert account_id
3846                row.account = account_id
3847                new_rows.append(row)
3848            assert new_rows
3849            assert date in data
3850            data[date] = new_rows
3851        if debug:
3852            print('after-names', names, len(names))
3853            assert names == self.names()
3854
3855        # do ops
3856        for date, rows in sorted(data.items()):
3857            try:
3858                def process(x: CSVRecord):
3859                    x.value = self.unscale(
3860                        x.value,
3861                        decimal_places=scale_decimal_places,
3862                    ) if scale_decimal_places > 0 else x.value
3863                    if x.rate > 0:
3864                        self.exchange(account=x.account, created_time_ns=x.date, rate=x.rate)
3865                    if x.value > 0:
3866                        self.track(unscaled_value=x.value, desc=x.desc, account=x.account, created_time_ns=x.date)
3867                    elif x.value < 0:
3868                        self.subtract(unscaled_value=-x.value, desc=x.desc, account=x.account, created_time_ns=x.date)
3869                    return x.hashed
3870                len_rows = len(rows)
3871                # If records are found at the same time with different accounts in the same amount
3872                # (one positive and the other negative), this indicates it is a transfer.
3873                if len_rows > 2 or len_rows == 1:
3874                    i = 0
3875                    for row in rows:
3876                        row.date += i
3877                        i += 1
3878                        hashed = process(row)
3879                        assert hashed not in cache
3880                        cache.append(hashed)
3881                        statistics.created += 1
3882                    continue
3883                x1 = rows[0]
3884                x2 = rows[1]
3885                if x1.account == x2.account:
3886                    continue
3887                    # raise Exception(f'invalid transfer')
3888                # not transfer - same time - normal ops
3889                if abs(x1.value) != abs(x2.value) and x1.date == x2.date:
3890                    rows[1].date += 1
3891                    for row in rows:
3892                        hashed = process(row)
3893                        assert hashed not in cache
3894                        cache.append(hashed)
3895                        statistics.created += 1
3896                    continue
3897                if x1.rate > 0:
3898                    self.exchange(x1.account, created_time_ns=x1.date, rate=x1.rate)
3899                if x2.rate > 0:
3900                    self.exchange(x2.account, created_time_ns=x2.date, rate=x2.rate)
3901                x1.value = self.unscale(
3902                    x1.value,
3903                    decimal_places=scale_decimal_places,
3904                ) if scale_decimal_places > 0 else x1.value
3905                x2.value = self.unscale(
3906                    x2.value,
3907                    decimal_places=scale_decimal_places,
3908                ) if scale_decimal_places > 0 else x2.value
3909                # just transfer
3910                values = {
3911                    x1.value: x1.account,
3912                    x2.value: x2.account,
3913                }
3914                if debug:
3915                    print('values', values)
3916                if len(values) <= 1:
3917                    continue
3918                self.transfer(
3919                    unscaled_amount=abs(x1.value),
3920                    from_account=values[min(values.keys())],
3921                    to_account=values[max(values.keys())],
3922                    desc=x1.desc,
3923                    created_time_ns=x1.date,
3924                )
3925            except Exception as e:
3926                for row in rows:
3927                    row.error = str(e)
3928                break
3929        if not self.memory_mode():
3930            with open(self.import_csv_cache_path(), 'w', encoding='utf-8') as stream:
3931                stream.write(json.dumps(cache))
3932        if no_lock:
3933            assert lock is not None
3934            self.free(lock)
3935        report = ImportReport(
3936            statistics=statistics,
3937            bad=[
3938                item
3939                for sublist in data.values()
3940                for item in sublist
3941                if item.error
3942            ],
3943        )
3944        if debug:
3945            debug_path = f'{self.import_csv_cache_path()}.debug.json'
3946            with open(debug_path, 'w', encoding='utf-8') as file:
3947                json.dump(report, file, indent=4, cls=JSONEncoder)
3948                print(f'generated debug report @ `{debug_path}`...')
3949        return report

The function reads the CSV file, checks for duplicate transactions and tries it's best to creates the transactions history accordingly 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.
  • delimiter (str, optional): The delimiter character to use in the CSV file. Defaults to ','.
  • debug (bool, optional): A flag indicating whether to print debug information.

Returns:

  • ImportReport: A dataclass 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 transfer, check and zakat operations.

Example: The CSV file should have the following format, rate and reference are optionals per transaction: account, desc, value, date, rate, reference For example: safe-45, 'Some text', 34872, 1988-06-30 00:00:00.000000, 1, 6554

@staticmethod
def human_readable_size(size: float, decimal_places: int = 2) -> str:
3955    @staticmethod
3956    def human_readable_size(size: float, decimal_places: int = 2) -> str:
3957        """
3958        Converts a size in bytes to a human-readable format (e.g., KB, MB, GB).
3959
3960        This function iterates through progressively larger units of information
3961        (B, KB, MB, GB, etc.) and divides the input size until it fits within a
3962        range that can be expressed with a reasonable number before the unit.
3963
3964        Parameters:
3965        - size (float): The size in bytes to convert.
3966        - decimal_places (int, optional): The number of decimal places to display
3967            in the result. Defaults to 2.
3968
3969        Returns:
3970        - str: A string representation of the size in a human-readable format,
3971            rounded to the specified number of decimal places. For example:
3972                - '1.50 KB' (1536 bytes)
3973                - '23.00 MB' (24117248 bytes)
3974                - '1.23 GB' (1325899906 bytes)
3975        """
3976        if type(size) not in (float, int):
3977            raise TypeError('size must be a float or integer')
3978        if type(decimal_places) != int:
3979            raise TypeError('decimal_places must be an integer')
3980        for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']:
3981            if size < 1024.0:
3982                break
3983            size /= 1024.0
3984        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:
3986    @staticmethod
3987    def get_dict_size(obj: dict, seen: Optional[set] = None) -> float:
3988        """
3989        Recursively calculates the approximate memory size of a dictionary and its contents in bytes.
3990
3991        This function traverses the dictionary structure, accounting for the size of keys, values,
3992        and any nested objects. It handles various data types commonly found in dictionaries
3993        (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case
3994        of circular references.
3995
3996        Parameters:
3997        - obj (dict): The dictionary whose size is to be calculated.
3998        - seen (set, optional): A set used internally to track visited objects
3999                             and avoid circular references. Defaults to None.
4000
4001        Returns:
4002         - float: An approximate size of the dictionary and its contents in bytes.
4003
4004        Notes:
4005        - This function is a method of the `ZakatTracker` class and is likely used to
4006          estimate the memory footprint of data structures relevant to Zakat calculations.
4007        - The size calculation is approximate as it relies on `sys.getsizeof()`, which might
4008          not account for all memory overhead depending on the Python implementation.
4009        - Circular references are handled to prevent infinite recursion.
4010        - Basic numeric types (int, float, complex) are assumed to have fixed sizes.
4011        - String sizes are estimated based on character length and encoding.
4012        """
4013        size = 0
4014        if seen is None:
4015            seen = set()
4016
4017        obj_id = id(obj)
4018        if obj_id in seen:
4019            return 0
4020
4021        seen.add(obj_id)
4022        size += sys.getsizeof(obj)
4023
4024        if isinstance(obj, dict):
4025            for k, v in obj.items():
4026                size += ZakatTracker.get_dict_size(k, seen)
4027                size += ZakatTracker.get_dict_size(v, seen)
4028        elif isinstance(obj, (list, tuple, set, frozenset)):
4029            for item in obj:
4030                size += ZakatTracker.get_dict_size(item, seen)
4031        elif isinstance(obj, (int, float, complex)):  # Handle numbers
4032            pass  # Basic numbers have a fixed size, so nothing to add here
4033        elif isinstance(obj, str):  # Handle strings
4034            size += len(obj) * sys.getsizeof(str().encode())  # Size per character in bytes
4035        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) -> Timestamp:
4037    @staticmethod
4038    def day_to_time(day: int, month: int = 6, year: int = 2024) -> Timestamp:  # افتراض أن الشهر هو يونيو والسنة 2024
4039        """
4040        Convert a specific day, month, and year into a timestamp.
4041
4042        Parameters:
4043        - day (int): The day of the month.
4044        - month (int, optional): The month of the year. Default is 6 (June).
4045        - year (int, optional): The year. Default is 2024.
4046
4047        Returns:
4048        - Timestamp: The timestamp representing the given day, month, and year.
4049
4050        Note:
4051        - This method assumes the default month and year if not provided.
4052        """
4053        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:

  • Timestamp: 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:
4055    @staticmethod
4056    def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
4057        """
4058        Generate a random date between two given dates.
4059
4060        Parameters:
4061        - start_date (datetime.datetime): The start date from which to generate a random date.
4062        - end_date (datetime.datetime): The end date until which to generate a random date.
4063
4064        Returns:
4065        - datetime.datetime: A random date between the start_date and end_date.
4066        """
4067        time_between_dates = end_date - start_date
4068        days_between_dates = time_between_dates.days
4069        random_number_of_days = random.randrange(days_between_dates)
4070        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, delimiter: str = ',', debug: bool = False) -> int:
4072    @staticmethod
4073    def generate_random_csv_file(path: str = 'data.csv',
4074                                 count: int = 1_000,
4075                                 with_rate: bool = False,
4076                                 delimiter: str = ',',
4077                                 debug: bool = False) -> int:
4078        """
4079        Generate a random CSV file with specified parameters.
4080        The function generates a CSV file at the specified path with the given count of rows.
4081        Each row contains a randomly generated account, description, value, and date.
4082        The value is randomly generated between 1000 and 100000,
4083        and the date is randomly generated between 1950-01-01 and 2023-12-31.
4084        If the row number is not divisible by 13, the value is multiplied by -1.
4085
4086        Parameters:
4087        - path (str, optional): The path where the CSV file will be saved. Default is 'data.csv'.
4088        - count (int, optional): The number of rows to generate in the CSV file. Default is 1000.
4089        - with_rate (bool, optional): If True, a random rate between 1.2% and 12% is added. Default is False.
4090        - delimiter (str, optional): The delimiter character to use in the CSV file. Defaults to ','.
4091        - debug (bool, optional): A flag indicating whether to print debug information.
4092
4093        Returns:
4094        - int: number of generated records.
4095        """
4096        if debug:
4097            print('generate_random_csv_file', f'debug={debug}')
4098        i = 0
4099        with open(path, 'w', newline='', encoding='utf-8') as csvfile:
4100            writer = csv.writer(csvfile, delimiter=delimiter)
4101            writer.writerow(ZakatTracker.get_transaction_csv_headers())
4102            for i in range(count):
4103                account = f'acc-{random.randint(1, count)}'
4104                desc = f'Some text {random.randint(1, count)}'
4105                value = random.randint(1000, 100000)
4106                date = ZakatTracker.generate_random_date(
4107                    datetime.datetime(1000, 1, 1),
4108                    datetime.datetime(2023, 12, 31),
4109                ).strftime('%Y-%m-%d %H:%M:%S.%f' if i % 2 == 0 else '%Y-%m-%d %H:%M:%S')
4110                if not i % 13 == 0:
4111                    value *= -1
4112                row = [account, desc, value, date]
4113                if with_rate:
4114                    rate = random.randint(1, 100) * 0.12
4115                    if debug:
4116                        print('before-append', row)
4117                    row.append(rate)
4118                    if debug:
4119                        print('after-append', row)
4120                if i % 2 == 1:
4121                    row += (Time.time(),)
4122                writer.writerow(row)
4123                i = i + 1
4124        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.
  • delimiter (str, optional): The delimiter character to use in the CSV file. Defaults to ','.
  • debug (bool, optional): A flag indicating whether to print debug information.

Returns:

  • int: number of generated records.
@staticmethod
def create_random_list(max_sum: int, min_value: int = 0, max_value: int = 10):
4126    @staticmethod
4127    def create_random_list(max_sum: int, min_value: int = 0, max_value: int = 10):
4128        """
4129        Creates a list of random integers whose sum does not exceed the specified maximum.
4130
4131        Parameters:
4132        - max_sum (int): The maximum allowed sum of the list elements.
4133        - min_value (int, optional): The minimum possible value for an element (inclusive).
4134        - max_value (int, optional): The maximum possible value for an element (inclusive).
4135
4136        Returns:
4137        - A list of random integers.
4138        """
4139        result = []
4140        current_sum = 0
4141
4142        while current_sum < max_sum:
4143            # Calculate the remaining space for the next element
4144            remaining_sum = max_sum - current_sum
4145            # Determine the maximum possible value for the next element
4146            next_max_value = min(remaining_sum, max_value)
4147            # Generate a random element within the allowed range
4148            next_element = random.randint(min_value, next_max_value)
4149            result.append(next_element)
4150            current_sum += next_element
4151
4152        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 backup( self, folder_path: str, output_directory: str = 'compressed', debug: bool = False) -> Optional[Backup]:
4154    def backup(self, folder_path: str, output_directory: str = "compressed", debug: bool = False) -> Optional[Backup]:
4155        """
4156        Compresses a folder into a .tar.lzma archive.
4157
4158        The archive is named following a specific format:
4159        'zakatdb_v<version>_<YYYYMMDD_HHMMSS>_<sha1hash>.tar.lzma'.  This format
4160        is crucial for the `restore` function, so avoid renaming the files.
4161
4162        Parameters:
4163        - folder_path (str): The path to the folder to be compressed.
4164        - output_directory (str, optional): The directory to save the compressed file.
4165                                        Defaults to "compressed".
4166        - debug (bool, optional): Whether to print debug information. Default is False.
4167
4168        Returns:
4169        - Optional[Backup]: A Backup object containing the path to the created archive
4170                            and its SHA1 hash on success, None on failure.
4171        """
4172        try:
4173            os.makedirs(output_directory, exist_ok=True)
4174            now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
4175
4176            # Create a temporary tar archive in memory to calculate the hash
4177            tar_buffer = io.BytesIO()
4178            with tarfile.open(fileobj=tar_buffer, mode="w") as tar:
4179                tar.add(folder_path, arcname=os.path.basename(folder_path))
4180            tar_buffer.seek(0)
4181            folder_hash = hashlib.sha1(tar_buffer.read()).hexdigest()
4182            output_filename = f"zakatdb_v{self.Version()}_{now}_{folder_hash}.tar.lzma"
4183            output_path = os.path.join(output_directory, output_filename)
4184
4185            # Compress the folder to the final .tar.lzma file
4186            with lzma.open(output_path, "wb") as lzma_file:
4187                tar_buffer.seek(0)  # Reset the buffer
4188                with tarfile.open(fileobj=lzma_file, mode="w") as tar:
4189                    tar.add(folder_path, arcname=os.path.basename(folder_path))
4190
4191            if debug:
4192                print(f"Folder '{folder_path}' has been compressed to '{output_path}'")
4193            return Backup(
4194                path=output_path,
4195                hash=folder_hash,
4196            )
4197        except Exception as e:
4198            print(f"Error during compression: {e}")
4199            return None

Compresses a folder into a .tar.lzma archive.

The archive is named following a specific format: 'zakatdb_v__.tar.lzma'. This format is crucial for the restore function, so avoid renaming the files.

Parameters:

  • folder_path (str): The path to the folder to be compressed.
  • output_directory (str, optional): The directory to save the compressed file. Defaults to "compressed".
  • debug (bool, optional): Whether to print debug information. Default is False.

Returns:

  • Optional[Backup]: A Backup object containing the path to the created archive and its SHA1 hash on success, None on failure.
def restore( self, tar_lzma_path: str, output_folder_path: str = 'uncompressed', debug: bool = False) -> bool:
4201    def restore(self, tar_lzma_path: str, output_folder_path: str = "uncompressed", debug: bool = False) -> bool:
4202        """
4203        Uncompresses a .tar.lzma archive and verifies its integrity using the SHA1 hash.
4204
4205        The SHA1 hash is extracted from the archive's filename, which must follow
4206        the format: 'zakatdb_v<version>_<YYYYMMDD_HHMMSS>_<sha1hash>.tar.lzma'.
4207        This format is essential for successful restoration.
4208
4209        Parameters:
4210        - tar_lzma_path (str): The path to the .tar.lzma file.
4211        - output_folder_path (str, optional): The directory to extract the contents to.
4212                                            Defaults to "uncompressed".
4213        - debug (bool, optional): Whether to print debug information. Default is False.
4214        
4215        Returns:
4216        - bool: True if the restoration was successful and the hash matches, False otherwise.
4217        """
4218        try:
4219            output_folder_path = pathlib.Path(output_folder_path).resolve()
4220            os.makedirs(output_folder_path, exist_ok=True)
4221            filename = os.path.basename(tar_lzma_path)
4222            match = re.match(r"zakatdb_v([^_]+)_(\d{8}_\d{6})_([a-f0-9]{40})\.tar\.lzma", filename)
4223            if not match:
4224                if debug:
4225                    print(f"Error: Invalid filename format: '{filename}'")
4226                return False
4227
4228            expected_hash_from_filename = match.group(3)
4229
4230            with lzma.open(tar_lzma_path, "rb") as lzma_file:
4231                tar_buffer = io.BytesIO(lzma_file.read())  # Read the entire decompressed tar into memory
4232                with tarfile.open(fileobj=tar_buffer, mode="r") as tar:
4233                    tar.extractall(output_folder_path)
4234                    tar_buffer.seek(0)  # Reset buffer to calculate hash
4235                    extracted_hash = hashlib.sha1(tar_buffer.read()).hexdigest()
4236
4237            new_path = os.path.join(output_folder_path, get_first_directory_inside(output_folder_path))
4238            assert os.path.exists(os.path.join(new_path, f"db.{self.ext()}")), f"Restored db.{self.ext()} not found."
4239            if extracted_hash == expected_hash_from_filename:
4240                if debug:
4241                    print(f"'{filename}' has been successfully uncompressed to '{output_folder_path}' and hash verified from filename.")
4242                now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
4243                old_path = os.path.dirname(self.path())
4244                tmp_path = os.path.join(os.path.dirname(old_path), "tmp_restore", now)
4245                if debug:
4246                    print('[xxx] - old_path:', old_path)
4247                    print('[xxx] - tmp_path:', tmp_path)
4248                    print('[xxx] - new_path:', new_path)
4249                try:
4250                    shutil.move(old_path, tmp_path)
4251                    shutil.move(new_path, old_path)
4252                    assert self.load()
4253                    shutil.rmtree(tmp_path)
4254                    return True
4255                except Exception as e:
4256                    print(f"Error applying the restored files: {e}")
4257                    shutil.move(tmp_path, old_path)
4258                    return False
4259            else:
4260                if debug:
4261                    print(f"Warning: Hash mismatch after uncompressing '{filename}'. Expected from filename: {expected_hash_from_filename}, Got: {extracted_hash}")
4262                # Optionally remove the extracted folder if the hash doesn't match
4263                # shutil.rmtree(output_folder_path, ignore_errors=True)
4264                return False
4265
4266        except Exception as e:
4267            print(f"Error during uncompression or hash check: {e}")
4268            return False

Uncompresses a .tar.lzma archive and verifies its integrity using the SHA1 hash.

The SHA1 hash is extracted from the archive's filename, which must follow the format: 'zakatdb_v__.tar.lzma'. This format is essential for successful restoration.

Parameters:

  • tar_lzma_path (str): The path to the .tar.lzma file.
  • output_folder_path (str, optional): The directory to extract the contents to. Defaults to "uncompressed".
  • debug (bool, optional): Whether to print debug information. Default is False.

Returns:

  • bool: True if the restoration was successful and the hash matches, False otherwise.
def test(self, debug: bool = False) -> bool:
4695    def test(self, debug: bool = False) -> bool:
4696        if debug:
4697            print('test', f'debug={debug}')
4698        try:
4699
4700            self._test_core(True, debug)
4701            self._test_core(False, debug)
4702
4703            # test_names
4704            self.reset()
4705            x = "test_names"
4706            failed = False
4707            try:
4708                assert self.name(x) == ''
4709            except:
4710                failed = True
4711            assert failed
4712            assert self.names() == {}
4713            failed = False
4714            try:
4715                assert self.name(x, 'qwe') == ''
4716            except:
4717                failed = True
4718            assert failed
4719            account_id0 = self.create_account(x)
4720            assert isinstance(account_id0, AccountID)
4721            assert int(account_id0) > 0
4722            assert self.name(account_id0) == x
4723            assert self.name(account_id0, 'qwe') == 'qwe'
4724            if debug:
4725                print(self.names(keyword='qwe'))
4726            assert self.names(keyword='asd') == {}
4727            assert self.names(keyword='qwe') == {'qwe': account_id0}
4728
4729            # test_create_account
4730            account_name = "test_account"
4731            assert self.names(keyword=account_name) == {}
4732            account_id = self.create_account(account_name)
4733            assert isinstance(account_id, AccountID)
4734            assert int(account_id) > 0
4735            assert account_id in self.__vault.account
4736            assert self.name(account_id) == account_name
4737            assert self.names(keyword=account_name) == {account_name: account_id}
4738
4739            failed = False
4740            try:
4741                self.create_account(account_name)
4742            except:
4743                failed = True
4744            assert failed
4745
4746            # bad are names is forbidden
4747
4748            for bad_name in [
4749                None,
4750                '',
4751                Time.time(),
4752                -Time.time(),
4753                f'{Time.time()}',
4754                f'{-Time.time()}',
4755                0.0,
4756                '0.0',
4757                ' ',
4758            ]:
4759                failed = False
4760                try:
4761                    self.create_account(bad_name)
4762                except:
4763                    failed = True
4764                assert failed
4765
4766            # rename account
4767            assert self.name(account_id) == account_name
4768            assert self.name(account_id, 'asd') == 'asd'
4769            assert self.name(account_id) == 'asd'
4770            # use old and not used name
4771            account_id2 = self.create_account(account_name)
4772            assert int(account_id2) > 0
4773            assert account_id != account_id2
4774            assert self.name(account_id2) == account_name
4775            assert self.names(keyword=account_name) == {account_name: account_id2}
4776
4777            assert self.__history()
4778            count = len(self.__vault.history)
4779            if debug:
4780                print('history-count', count)
4781            assert count == 8
4782
4783            assert self.recall(dry=False, debug=debug)
4784            assert self.name(account_id2) == ''
4785            assert self.account_exists(account_id2)
4786            assert self.recall(dry=False, debug=debug)
4787            assert not self.account_exists(account_id2)
4788            assert self.recall(dry=False, debug=debug)
4789            assert self.name(account_id) == account_name
4790            assert self.recall(dry=False, debug=debug)
4791            assert self.account_exists(account_id)
4792            assert self.recall(dry=False, debug=debug)
4793            assert not self.account_exists(account_id)
4794            assert self.names(keyword='qwe') == {'qwe': account_id0}
4795            assert self.recall(dry=False, debug=debug)
4796            assert self.names(keyword='qwe') == {}
4797            assert self.name(account_id0) == x
4798            assert self.recall(dry=False, debug=debug)
4799            assert self.name(account_id0) == ''
4800            assert self.account_exists(account_id0)
4801            assert self.recall(dry=False, debug=debug)
4802            assert not self.account_exists(account_id0)
4803            assert not self.recall(dry=False, debug=debug)
4804
4805            # Not allowed for duplicate transactions in the same account and time
4806
4807            created = Time.time()
4808            same_account_id = self.create_account('same')
4809            self.track(100, 'test-1', same_account_id, True, created)
4810            failed = False
4811            try:
4812                self.track(50, 'test-1', same_account_id, True, created)
4813            except:
4814                failed = True
4815            assert failed is True
4816
4817            self.reset()
4818
4819            # Same account transfer
4820            for x in [1, 'a', True, 1.8, None]:
4821                failed = False
4822                try:
4823                    self.transfer(1, x, x, 'same-account', debug=debug)
4824                except:
4825                    failed = True
4826                assert failed is True
4827
4828            # Always preserve box age during transfer
4829
4830            series: list[tuple[int, int]] = [
4831                (30, 4),
4832                (60, 3),
4833                (90, 2),
4834            ]
4835            case = {
4836                3000: {
4837                    'series': series,
4838                    'rest': 15000,
4839                },
4840                6000: {
4841                    'series': series,
4842                    'rest': 12000,
4843                },
4844                9000: {
4845                    'series': series,
4846                    'rest': 9000,
4847                },
4848                18000: {
4849                    'series': series,
4850                    'rest': 0,
4851                },
4852                27000: {
4853                    'series': series,
4854                    'rest': -9000,
4855                },
4856                36000: {
4857                    'series': series,
4858                    'rest': -18000,
4859                },
4860            }
4861
4862            selected_time = Time.time() - ZakatTracker.TimeCycle()
4863            ages_account_id = self.create_account('ages')
4864            future_account_id = self.create_account('future')
4865
4866            for total in case:
4867                if debug:
4868                    print('--------------------------------------------------------')
4869                    print(f'case[{total}]', case[total])
4870                for x in case[total]['series']:
4871                    self.track(
4872                        unscaled_value=x[0],
4873                        desc=f'test-{x} ages',
4874                        account=ages_account_id,
4875                        created_time_ns=selected_time * x[1],
4876                    )
4877
4878                unscaled_total = self.unscale(total)
4879                if debug:
4880                    print('unscaled_total', unscaled_total)
4881                refs = self.transfer(
4882                    unscaled_amount=unscaled_total,
4883                    from_account=ages_account_id,
4884                    to_account=future_account_id,
4885                    desc='Zakat Movement',
4886                    debug=debug,
4887                )
4888
4889                if debug:
4890                    print('refs', refs)
4891
4892                ages_cache_balance = self.balance(ages_account_id)
4893                ages_fresh_balance = self.balance(ages_account_id, False)
4894                rest = case[total]['rest']
4895                if debug:
4896                    print('source', ages_cache_balance, ages_fresh_balance, rest)
4897                assert ages_cache_balance == rest
4898                assert ages_fresh_balance == rest
4899
4900                future_cache_balance = self.balance(future_account_id)
4901                future_fresh_balance = self.balance(future_account_id, False)
4902                if debug:
4903                    print('target', future_cache_balance, future_fresh_balance, total)
4904                    print('refs', refs)
4905                assert future_cache_balance == total
4906                assert future_fresh_balance == total
4907
4908                # TODO: check boxes times for `ages` should equal box times in `future`
4909                for ref in self.__vault.account[ages_account_id].box:
4910                    ages_capital = self.__vault.account[ages_account_id].box[ref].capital
4911                    ages_rest = self.__vault.account[ages_account_id].box[ref].rest
4912                    future_capital = 0
4913                    if ref in self.__vault.account[future_account_id].box:
4914                        future_capital = self.__vault.account[future_account_id].box[ref].capital
4915                    future_rest = 0
4916                    if ref in self.__vault.account[future_account_id].box:
4917                        future_rest = self.__vault.account[future_account_id].box[ref].rest
4918                    if ages_capital != 0 and future_capital != 0 and future_rest != 0:
4919                        if debug:
4920                            print('================================================================')
4921                            print('ages', ages_capital, ages_rest)
4922                            print('future', future_capital, future_rest)
4923                        if ages_rest == 0:
4924                            assert ages_capital == future_capital
4925                        elif ages_rest < 0:
4926                            assert -ages_capital == future_capital
4927                        elif ages_rest > 0:
4928                            assert ages_capital == ages_rest + future_capital
4929                self.reset()
4930                assert len(self.__vault.history) == 0
4931
4932            assert self.__history()
4933            assert self.__history(False) is False
4934            assert self.__history() is False
4935            assert self.__history(True)
4936            assert self.__history()
4937            if debug:
4938                print('####################################################################')
4939
4940            wallet_account_id = self.create_account('wallet')
4941            safe_account_id = self.create_account('safe')
4942            bank_account_id = self.create_account('bank')
4943            transaction = [
4944                (
4945                    20, wallet_account_id, AccountID(1), -2000, -2000, -2000, 1, 1,
4946                    2000, 2000, 2000, 1, 1,
4947                ),
4948                (
4949                    750, wallet_account_id, safe_account_id, -77000, -77000, -77000, 2, 2,
4950                    75000, 75000, 75000, 1, 1,
4951                ),
4952                (
4953                    600, safe_account_id, bank_account_id, 15000, 15000, 15000, 1, 2,
4954                    60000, 60000, 60000, 1, 1,
4955                ),
4956            ]
4957            for z in transaction:
4958                lock = self.lock()
4959                x = z[1]
4960                y = z[2]
4961                self.transfer(
4962                    unscaled_amount=z[0],
4963                    from_account=x,
4964                    to_account=y,
4965                    desc='test-transfer',
4966                    debug=debug,
4967                )
4968                zz = self.balance(x)
4969                if debug:
4970                    print(zz, z)
4971                assert zz == z[3]
4972                xx = self.accounts()[x]
4973                assert xx.balance == z[3]
4974                assert self.balance(x, False) == z[4]
4975                assert xx.balance == z[4]
4976
4977                s = 0
4978                log = self.__vault.account[x].log
4979                for i in log:
4980                    s += log[i].value
4981                if debug:
4982                    print('s', s, 'z[5]', z[5])
4983                assert s == z[5]
4984
4985                assert self.box_size(x) == z[6]
4986                assert self.log_size(x) == z[7]
4987
4988                yy = self.accounts()[y]
4989                assert self.balance(y) == z[8]
4990                assert yy.balance == z[8]
4991                assert self.balance(y, False) == z[9]
4992                assert yy.balance == z[9]
4993
4994                s = 0
4995                log = self.__vault.account[y].log
4996                for i in log:
4997                    s += log[i].value
4998                assert s == z[10]
4999
5000                assert self.box_size(y) == z[11]
5001                assert self.log_size(y) == z[12]
5002                assert lock is not None
5003                assert self.free(lock)
5004
5005            assert self.nolock()
5006            history_count = len(self.__vault.history)
5007            transaction_count = len(transaction)
5008            if debug:
5009                print('history-count', history_count, transaction_count)
5010            assert history_count == transaction_count * 3
5011            assert not self.free(Time.time())
5012            assert self.free(self.lock())
5013            assert self.nolock()
5014            assert len(self.__vault.history) == transaction_count * 3
5015
5016            # recall
5017
5018            assert self.nolock()
5019            for i in range(transaction_count * 3, 0, -1):
5020                assert len(self.__vault.history) == i
5021                assert self.recall(dry=False, debug=debug) is True
5022            assert len(self.__vault.history) == 0
5023            assert self.recall(dry=False, debug=debug) is False
5024            assert len(self.__vault.history) == 0
5025
5026            # exchange
5027
5028            cash_account_id = self.create_account('cash')
5029            self.exchange(cash_account_id, 25, 3.75, '2024-06-25')
5030            self.exchange(cash_account_id, 22, 3.73, '2024-06-22')
5031            self.exchange(cash_account_id, 15, 3.69, '2024-06-15')
5032            self.exchange(cash_account_id, 10, 3.66)
5033
5034            assert self.nolock()
5035
5036            bank_account_id = self.create_account('bank')
5037            for i in range(1, 30):
5038                exchange = self.exchange(cash_account_id, i)
5039                rate, description, created = exchange.rate, exchange.description, exchange.time
5040                if debug:
5041                    print(i, rate, description, created)
5042                assert created
5043                if i < 10:
5044                    assert rate == 1
5045                    assert description is None
5046                elif i == 10:
5047                    assert rate == 3.66
5048                    assert description is None
5049                elif i < 15:
5050                    assert rate == 3.66
5051                    assert description is None
5052                elif i == 15:
5053                    assert rate == 3.69
5054                    assert description is not None
5055                elif i < 22:
5056                    assert rate == 3.69
5057                    assert description is not None
5058                elif i == 22:
5059                    assert rate == 3.73
5060                    assert description is not None
5061                elif i >= 25:
5062                    assert rate == 3.75
5063                    assert description is not None
5064                exchange = self.exchange(bank_account_id, i)
5065                rate, description, created = exchange.rate, exchange.description, exchange.time
5066                if debug:
5067                    print(i, rate, description, created)
5068                assert created
5069                assert rate == 1
5070                assert description is None
5071
5072            assert len(self.__vault.exchange) == 1
5073            assert len(self.exchanges()) == 1
5074            self.__vault.exchange.clear()
5075            assert len(self.__vault.exchange) == 0
5076            assert len(self.exchanges()) == 0
5077            self.reset()
5078
5079            # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية
5080            cash_account_id = self.create_account('cash')
5081            self.exchange(cash_account_id, ZakatTracker.day_to_time(25), 3.75, '2024-06-25')
5082            self.exchange(cash_account_id, ZakatTracker.day_to_time(22), 3.73, '2024-06-22')
5083            self.exchange(cash_account_id, ZakatTracker.day_to_time(15), 3.69, '2024-06-15')
5084            self.exchange(cash_account_id, ZakatTracker.day_to_time(10), 3.66)
5085
5086            assert self.nolock()
5087
5088            test_account_id = self.create_account('test')
5089            for i in [x * 0.12 for x in range(-15, 21)]:
5090                if i <= 0:
5091                    assert self.exchange(test_account_id, Time.time(), i, f'range({i})') == Exchange()
5092                else:
5093                    assert self.exchange(test_account_id, Time.time(), i, f'range({i})') is not Exchange()
5094
5095            assert self.nolock()
5096
5097           # اختبار النتائج باستخدام التواريخ بالنانو ثانية
5098            bank_account_id = self.create_account('bank')
5099            for i in range(1, 31):
5100                timestamp_ns = ZakatTracker.day_to_time(i)
5101                exchange = self.exchange(cash_account_id, timestamp_ns)
5102                rate, description, created = exchange.rate, exchange.description, exchange.time
5103                if debug:
5104                    print(i, rate, description, created)
5105                assert created
5106                if i < 10:
5107                    assert rate == 1
5108                    assert description is None
5109                elif i == 10:
5110                    assert rate == 3.66
5111                    assert description is None
5112                elif i < 15:
5113                    assert rate == 3.66
5114                    assert description is None
5115                elif i == 15:
5116                    assert rate == 3.69
5117                    assert description is not None
5118                elif i < 22:
5119                    assert rate == 3.69
5120                    assert description is not None
5121                elif i == 22:
5122                    assert rate == 3.73
5123                    assert description is not None
5124                elif i >= 25:
5125                    assert rate == 3.75
5126                    assert description is not None
5127                exchange = self.exchange(bank_account_id, i)
5128                rate, description, created = exchange.rate, exchange.description, exchange.time
5129                if debug:
5130                    print(i, rate, description, created)
5131                assert created
5132                assert rate == 1
5133                assert description is None
5134
5135            assert self.nolock()
5136            if debug:
5137                print(self.__vault.history, len(self.__vault.history))
5138            for _ in range(len(self.__vault.history)):
5139                assert self.recall(dry=False, debug=debug)
5140            assert not self.recall(dry=False, debug=debug)
5141
5142            self.reset()
5143
5144            # test transfer between accounts with different exchange rate
5145
5146            a_SAR = self.create_account('Bank (SAR)')
5147            b_USD = self.create_account('Bank (USD)')
5148            c_SAR = self.create_account('Safe (SAR)')
5149            # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer
5150            for case in [
5151                (0, a_SAR, 'SAR Gift', 1000, 100000),
5152                (1, a_SAR, 1),
5153                (0, b_USD, 'USD Gift', 500, 50000),
5154                (1, b_USD, 1),
5155                (2, b_USD, 3.75),
5156                (1, b_USD, 3.75),
5157                (3, 100, b_USD, a_SAR, '100 USD -> SAR', 40000, 137500),
5158                (0, c_SAR, 'Salary', 750, 75000),
5159                (3, 375, c_SAR, b_USD, '375 SAR -> USD', 37500, 50000),
5160                (3, 3.75, a_SAR, b_USD, '3.75 SAR -> USD', 137125, 50100),
5161            ]:
5162                if debug:
5163                    print('case', case)
5164                match (case[0]):
5165                    case 0:  # track
5166                        _, account, desc, x, balance = case
5167                        self.track(unscaled_value=x, desc=desc, account=account, debug=debug)
5168
5169                        cached_value = self.balance(account, cached=True)
5170                        fresh_value = self.balance(account, cached=False)
5171                        if debug:
5172                            print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value)
5173                        assert cached_value == balance
5174                        assert fresh_value == balance
5175                    case 1:  # check-exchange
5176                        _, account, expected_rate = case
5177                        t_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug)
5178                        if debug:
5179                            print('t-exchange', t_exchange)
5180                        assert t_exchange.rate == expected_rate
5181                    case 2:  # do-exchange
5182                        _, account, rate = case
5183                        self.exchange(account, rate=rate, debug=debug)
5184                        b_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug)
5185                        if debug:
5186                            print('b-exchange', b_exchange)
5187                        assert b_exchange.rate == rate
5188                    case 3:  # transfer
5189                        _, x, a, b, desc, a_balance, b_balance = case
5190                        self.transfer(x, a, b, desc, debug=debug)
5191
5192                        cached_value = self.balance(a, cached=True)
5193                        fresh_value = self.balance(a, cached=False)
5194                        if debug:
5195                            print(
5196                                'account', a,
5197                                'cached_value', cached_value,
5198                                'fresh_value', fresh_value,
5199                                'a_balance', a_balance,
5200                            )
5201                        assert cached_value == a_balance
5202                        assert fresh_value == a_balance
5203
5204                        cached_value = self.balance(b, cached=True)
5205                        fresh_value = self.balance(b, cached=False)
5206                        if debug:
5207                            print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value)
5208                        assert cached_value == b_balance
5209                        assert fresh_value == b_balance
5210
5211            # Transfer all in many chunks randomly from B to A
5212            a_SAR_balance = 137125
5213            b_USD_balance = 50100
5214            b_USD_exchange = self.exchange(b_USD)
5215            amounts = ZakatTracker.create_random_list(b_USD_balance, max_value=1000)
5216            if debug:
5217                print('amounts', amounts)
5218            i = 0
5219            for x in amounts:
5220                if debug:
5221                    print(f'{i} - transfer-with-exchange({x})')
5222                self.transfer(
5223                    unscaled_amount=self.unscale(x),
5224                    from_account=b_USD,
5225                    to_account=a_SAR,
5226                    desc=f'{x} USD -> SAR',
5227                    debug=debug,
5228                )
5229
5230                b_USD_balance -= x
5231                cached_value = self.balance(b_USD, cached=True)
5232                fresh_value = self.balance(b_USD, cached=False)
5233                if debug:
5234                    print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
5235                          b_USD_balance)
5236                assert cached_value == b_USD_balance
5237                assert fresh_value == b_USD_balance
5238
5239                a_SAR_balance += int(x * b_USD_exchange.rate)
5240                cached_value = self.balance(a_SAR, cached=True)
5241                fresh_value = self.balance(a_SAR, cached=False)
5242                if debug:
5243                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
5244                          a_SAR_balance, 'rate', b_USD_exchange.rate)
5245                assert cached_value == a_SAR_balance
5246                assert fresh_value == a_SAR_balance
5247                i += 1
5248
5249            # Transfer all in many chunks randomly from C to A
5250            c_SAR_balance = 37500
5251            amounts = ZakatTracker.create_random_list(c_SAR_balance, max_value=1000)
5252            if debug:
5253                print('amounts', amounts)
5254            i = 0
5255            for x in amounts:
5256                if debug:
5257                    print(f'{i} - transfer-with-exchange({x})')
5258                self.transfer(
5259                    unscaled_amount=self.unscale(x),
5260                    from_account=c_SAR,
5261                    to_account=a_SAR,
5262                    desc=f'{x} SAR -> a_SAR',
5263                    debug=debug,
5264                )
5265
5266                c_SAR_balance -= x
5267                cached_value = self.balance(c_SAR, cached=True)
5268                fresh_value = self.balance(c_SAR, cached=False)
5269                if debug:
5270                    print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
5271                          c_SAR_balance)
5272                assert cached_value == c_SAR_balance
5273                assert fresh_value == c_SAR_balance
5274
5275                a_SAR_balance += x
5276                cached_value = self.balance(a_SAR, cached=True)
5277                fresh_value = self.balance(a_SAR, cached=False)
5278                if debug:
5279                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
5280                          a_SAR_balance)
5281                assert cached_value == a_SAR_balance
5282                assert fresh_value == a_SAR_balance
5283                i += 1
5284
5285            assert self.save(f'accounts-transfer-with-exchange-rates.{self.ext()}')
5286
5287            # check & zakat with exchange rates for many cycles
5288
5289            lock = None
5290            safe_account_id = self.create_account('safe')
5291            cave_account_id = self.create_account('cave')
5292            for rate, values in {
5293                1: {
5294                    'in': [1000, 2000, 10000],
5295                    'exchanged': [100000, 200000, 1000000],
5296                    'out': [2500, 5000, 73140],
5297                },
5298                3.75: {
5299                    'in': [200, 1000, 5000],
5300                    'exchanged': [75000, 375000, 1875000],
5301                    'out': [1875, 9375, 137138],
5302                },
5303            }.items():
5304                a, b, c = values['in']
5305                m, n, o = values['exchanged']
5306                x, y, z = values['out']
5307                if debug:
5308                    print('rate', rate, 'values', values)
5309                for case in [
5310                    (a, safe_account_id, Time.time() - ZakatTracker.TimeCycle(), [
5311                        {safe_account_id: {0: {'below_nisab': x}}},
5312                    ], False, m),
5313                    (b, safe_account_id, Time.time() - ZakatTracker.TimeCycle(), [
5314                        {safe_account_id: {0: {'count': 1, 'total': y}}},
5315                    ], True, n),
5316                    (c, cave_account_id, Time.time() - (ZakatTracker.TimeCycle() * 3), [
5317                        {cave_account_id: {0: {'count': 3, 'total': z}}},
5318                    ], True, o),
5319                ]:
5320                    if debug:
5321                        print(f'############# check(rate: {rate}) #############')
5322                        print('case', case)
5323                    self.reset()
5324                    self.exchange(account=case[1], created_time_ns=case[2], rate=rate)
5325                    self.track(
5326                        unscaled_value=case[0],
5327                        desc='test-check',
5328                        account=case[1],
5329                        created_time_ns=case[2],
5330                    )
5331                    assert self.snapshot()
5332
5333                    # assert self.nolock()
5334                    # history_size = len(self.__vault.history)
5335                    # print('history_size', history_size)
5336                    # assert history_size == 2
5337                    lock = self.lock()
5338                    assert lock
5339                    assert not self.nolock()
5340                    assert self.__vault.cache.zakat is None
5341                    report = self.check(2.17, None, debug)
5342                    if debug:
5343                        print('[report]', report)
5344                    assert case[4] == report.valid
5345                    assert case[5] == report.summary.total_wealth
5346                    assert case[5] == report.summary.total_zakatable_amount
5347                    if report.valid:
5348                        assert self.__vault.cache.zakat is not None
5349                        assert report.plan
5350                        assert self.zakat(report, debug=debug)
5351                        assert self.__vault.cache.zakat is None
5352                        if debug:
5353                            pp().pprint(self.__vault)
5354                        self._test_storage(debug=debug)
5355
5356                        for x in report.plan:
5357                            assert case[1] == x
5358                            if report.plan[x][0].below_nisab:
5359                                if debug:
5360                                    print('[assert]', report.plan[x][0].total, case[3][0][x][0]['below_nisab'])
5361                                assert report.plan[x][0].total == case[3][0][x][0]['below_nisab']
5362                            else:
5363                                if debug:
5364                                    print('[assert]', int(report.summary.total_zakat_due), case[3][0][x][0]['total'])
5365                                    print('[assert]', int(report.plan[x][0].total), case[3][0][x][0]['total'])
5366                                    print('[assert]', report.plan[x][0].count ,case[3][0][x][0]['count'])
5367                                assert int(report.summary.total_zakat_due) == case[3][0][x][0]['total']
5368                                assert int(report.plan[x][0].total) == case[3][0][x][0]['total']
5369                                assert report.plan[x][0].count == case[3][0][x][0]['count']
5370                    else:
5371                        assert self.__vault.cache.zakat is None
5372                        result = self.zakat(report, debug=debug)
5373                        if debug:
5374                            print('zakat-result', result, case[4])
5375                        assert result == case[4]
5376                        report = self.check(2.17, None, debug)
5377                        assert report.valid is False
5378            self._test_storage(account_id=cave_account_id, debug=debug)
5379
5380            # recall after zakat
5381
5382            history_size = len(self.__vault.history)
5383            if debug:
5384                print('history_size', history_size)
5385            assert history_size == 3
5386            assert not self.nolock()
5387            assert self.recall(dry=False, debug=debug) is False
5388            self.free(lock)
5389            assert self.nolock()
5390
5391            for i in range(3, 0, -1):
5392                history_size = len(self.__vault.history)
5393                if debug:
5394                    print('history_size', history_size)
5395                assert history_size == i
5396                assert self.recall(dry=False, debug=debug) is True
5397
5398            assert self.nolock()
5399            assert self.recall(dry=False, debug=debug) is False
5400
5401            history_size = len(self.__vault.history)
5402            if debug:
5403                print('history_size', history_size)
5404            assert history_size == 0
5405
5406            account_size = len(self.__vault.account)
5407            if debug:
5408                print('account_size', account_size)
5409            assert account_size == 0
5410
5411            report_size = len(self.__vault.report)
5412            if debug:
5413                print('report_size', report_size)
5414            assert report_size == 0
5415
5416            assert self.nolock()
5417
5418            # csv
5419
5420            csv_count = 1000
5421
5422            for with_rate, path in {
5423                False: 'test-import_csv-no-exchange',
5424                True: 'test-import_csv-with-exchange',
5425            }.items():
5426
5427                if debug:
5428                    print('test_import_csv', with_rate, path)
5429
5430                csv_path = path + '.csv'
5431                if os.path.exists(csv_path):
5432                    os.remove(csv_path)
5433                c = self.generate_random_csv_file(
5434                    path=csv_path,
5435                    count=csv_count,
5436                    with_rate=with_rate,
5437                    debug=debug,
5438                )
5439                if debug:
5440                    print('generate_random_csv_file', c)
5441                assert c == csv_count
5442                assert os.path.getsize(csv_path) > 0
5443                cache_path = self.import_csv_cache_path()
5444                if os.path.exists(cache_path):
5445                    os.remove(cache_path)
5446                self.reset()
5447                lock = self.lock()
5448                import_report = self.import_csv(csv_path, debug=debug)
5449                bad_count = len(import_report.bad)
5450                if debug:
5451                    print(f'csv-imported: {import_report.statistics} = count({csv_count})')
5452                    print('bad', import_report.bad)
5453                assert import_report.statistics.created + import_report.statistics.found + bad_count == csv_count
5454                assert import_report.statistics.created == csv_count
5455                assert bad_count == 0
5456                assert bad_count == import_report.statistics.bad
5457                tmp_size = os.path.getsize(cache_path)
5458                assert tmp_size > 0
5459
5460                import_report_2 = self.import_csv(csv_path, debug=debug)
5461                bad_2_count = len(import_report_2.bad)
5462                if debug:
5463                    print(f'csv-imported: {import_report_2}')
5464                    print('bad', import_report_2.bad)
5465                assert tmp_size == os.path.getsize(cache_path)
5466                assert import_report_2.statistics.created + import_report_2.statistics.found + bad_2_count == csv_count
5467                assert import_report.statistics.created == import_report_2.statistics.found
5468                assert bad_count == bad_2_count
5469                assert import_report_2.statistics.found == csv_count
5470                assert bad_2_count == 0
5471                assert bad_2_count == import_report_2.statistics.bad
5472                assert import_report_2.statistics.created == 0
5473
5474                # payment parts
5475
5476                positive_parts = self.build_payment_parts(100, positive_only=True)
5477                assert self.check_payment_parts(positive_parts) != 0
5478                assert self.check_payment_parts(positive_parts) != 0
5479                all_parts = self.build_payment_parts(300, positive_only=False)
5480                assert self.check_payment_parts(all_parts) != 0
5481                assert self.check_payment_parts(all_parts) != 0
5482                if debug:
5483                    pp().pprint(positive_parts)
5484                    pp().pprint(all_parts)
5485                # dynamic discount
5486                suite = []
5487                count = 3
5488                for exceed in [False, True]:
5489                    case = []
5490                    for part in [positive_parts, all_parts]:
5491                        #part = parts.copy()
5492                        demand = part.demand
5493                        if debug:
5494                            print(demand, part.total)
5495                        i = 0
5496                        z = demand / count
5497                        cp = PaymentParts(
5498                            demand=demand,
5499                            exceed=exceed,
5500                            total=part.total,
5501                        )
5502                        j = ''
5503                        for x, y in part.account.items():
5504                            x_exchange = self.exchange(x)
5505                            zz = self.exchange_calc(z, 1, x_exchange.rate)
5506                            if exceed and zz <= demand:
5507                                i += 1
5508                                y.part = zz
5509                                if debug:
5510                                    print(exceed, y)
5511                                cp.account[x] = y
5512                                case.append(y)
5513                            elif not exceed and y.balance >= zz:
5514                                i += 1
5515                                y.part = zz
5516                                if debug:
5517                                    print(exceed, y)
5518                                cp.account[x] = y
5519                                case.append(y)
5520                            j = x
5521                            if i >= count:
5522                                break
5523                        if debug:
5524                            print('[debug]', j)
5525                            print('[debug]', cp.account[j])
5526                        if cp.account[j] != AccountPaymentPart(0.0, 0.0, 0.0):
5527                            suite.append(cp)
5528                if debug:
5529                    print('suite', len(suite))
5530                for case in suite:
5531                    if debug:
5532                        print('case', case)
5533                    result = self.check_payment_parts(case)
5534                    if debug:
5535                        print('check_payment_parts', result, f'exceed: {exceed}')
5536                    assert result == 0
5537
5538                    assert self.__vault.cache.zakat is None
5539                    report = self.check(2.17, None, debug)
5540                    if debug:
5541                        print('valid', report.valid)
5542                    zakat_result = self.zakat(report, parts=case, debug=debug)
5543                    if debug:
5544                        print('zakat-result', zakat_result)
5545                    assert report.valid == zakat_result
5546                    # test verified zakat report is required
5547                    if zakat_result:
5548                        assert self.__vault.cache.zakat is None
5549                        failed = False
5550                        try:
5551                            self.zakat(report, parts=case, debug=debug)
5552                        except:
5553                            failed = True
5554                        assert failed
5555
5556                assert self.free(lock)
5557
5558            assert self.save(path + f'.{self.ext()}')
5559            assert self.save(f'1000-transactions-test.{self.ext()}')
5560            return True
5561        except Exception as e:
5562            if self.__debug_output:
5563                pp().pprint(self.__vault)
5564                print('============================================================================')
5565                pp().pprint(self.__debug_output)
5566            assert self.save(f'test-snapshot.{self.ext()}')
5567            raise e
class AccountID(builtins.str):
250class AccountID(str):
251    """
252    A class representing an Account ID, which is a string that must be a positive integer greater than zero.
253    Inherits from str, so it behaves like a string.
254    """
255
256    def __new__(cls, value):
257        """
258        Creates a new AccountID instance.
259
260        Parameters:
261        - value (str): The string value to be used as the AccountID.
262
263        Raises:
264        - ValueError: If the provided value is not a valid AccountID.
265
266        Returns:
267        - AccountID: A new AccountID instance.
268        """
269        if isinstance(value, Timestamp):
270            value = str(value) # convert timestamp to string
271        if not cls.is_valid_account_id(value):
272            raise ValueError(f"Invalid AccountID: '{value}'")
273        return super().__new__(cls, value)
274
275    @staticmethod
276    def is_valid_account_id(s: str) -> bool:
277        """
278        Checks if a string is a valid AccountID (positive integer greater than zero).
279
280        Parameters:
281        - s (str): The string to check.
282
283        Returns:
284         - bool: True if the string is a valid AccountID, False otherwise.
285        """
286        if not s:
287            return False
288
289        try:
290            if s[0] == '0':
291                return False
292            if s.startswith('-'):
293                return False
294            if not s.isdigit():
295                return False
296        except:
297            pass
298
299        try:
300            num = int(s)
301            return num > 0
302        except ValueError:
303            return False
304
305    @classmethod
306    def test(cls, debug: bool = False):
307        """
308        Runs tests for the AccountID class to ensure it behaves correctly.
309
310        This method tests various valid and invalid input strings to verify that:
311            - Valid AccountIDs are created successfully.
312            - Invalid AccountIDs raise ValueError exceptions.
313        """
314        test_data = {
315            "123": True,
316            "0": False,
317            "01": False,
318            "-1": False,
319            "abc": False,
320            "12.3": False,
321            "": False,
322            "9999999999999999999999999999999999999": True,
323            "1": True,
324            "10": True,
325            "000000000000000001": False,
326            " ": False,
327            "1 ": False,
328            " 1": False,
329            "1.0": False,
330            Timestamp(12345): True, # Test timestamp input
331        }
332
333        for input_value, expected_output in test_data.items():
334            if expected_output:
335                try:
336                    account_id = cls(input_value)
337                    if debug:
338                        print(f'"{str(account_id)}", "{input_value}"')
339                    if isinstance(input_value, Timestamp):
340                        input_value = str(input_value)
341                    assert str(account_id) == input_value, f"Test failed for valid input: '{input_value}'"
342                except ValueError as e:
343                    assert False, f"Unexpected ValueError for valid input: '{input_value}': {e}"
344            else:
345                try:
346                    cls(input_value)
347                    assert False, f"Expected ValueError for invalid input: '{input_value}'"
348                except ValueError as e:
349                    pass  # Expected exception

A class representing an Account ID, which is a string that must be a positive integer greater than zero. Inherits from str, so it behaves like a string.

AccountID(value)
256    def __new__(cls, value):
257        """
258        Creates a new AccountID instance.
259
260        Parameters:
261        - value (str): The string value to be used as the AccountID.
262
263        Raises:
264        - ValueError: If the provided value is not a valid AccountID.
265
266        Returns:
267        - AccountID: A new AccountID instance.
268        """
269        if isinstance(value, Timestamp):
270            value = str(value) # convert timestamp to string
271        if not cls.is_valid_account_id(value):
272            raise ValueError(f"Invalid AccountID: '{value}'")
273        return super().__new__(cls, value)

Creates a new AccountID instance.

Parameters:

  • value (str): The string value to be used as the AccountID.

Raises:

  • ValueError: If the provided value is not a valid AccountID.

Returns:

  • AccountID: A new AccountID instance.
@staticmethod
def is_valid_account_id(s: str) -> bool:
275    @staticmethod
276    def is_valid_account_id(s: str) -> bool:
277        """
278        Checks if a string is a valid AccountID (positive integer greater than zero).
279
280        Parameters:
281        - s (str): The string to check.
282
283        Returns:
284         - bool: True if the string is a valid AccountID, False otherwise.
285        """
286        if not s:
287            return False
288
289        try:
290            if s[0] == '0':
291                return False
292            if s.startswith('-'):
293                return False
294            if not s.isdigit():
295                return False
296        except:
297            pass
298
299        try:
300            num = int(s)
301            return num > 0
302        except ValueError:
303            return False

Checks if a string is a valid AccountID (positive integer greater than zero).

Parameters:

  • s (str): The string to check.

Returns:

  • bool: True if the string is a valid AccountID, False otherwise.
@classmethod
def test(cls, debug: bool = False):
305    @classmethod
306    def test(cls, debug: bool = False):
307        """
308        Runs tests for the AccountID class to ensure it behaves correctly.
309
310        This method tests various valid and invalid input strings to verify that:
311            - Valid AccountIDs are created successfully.
312            - Invalid AccountIDs raise ValueError exceptions.
313        """
314        test_data = {
315            "123": True,
316            "0": False,
317            "01": False,
318            "-1": False,
319            "abc": False,
320            "12.3": False,
321            "": False,
322            "9999999999999999999999999999999999999": True,
323            "1": True,
324            "10": True,
325            "000000000000000001": False,
326            " ": False,
327            "1 ": False,
328            " 1": False,
329            "1.0": False,
330            Timestamp(12345): True, # Test timestamp input
331        }
332
333        for input_value, expected_output in test_data.items():
334            if expected_output:
335                try:
336                    account_id = cls(input_value)
337                    if debug:
338                        print(f'"{str(account_id)}", "{input_value}"')
339                    if isinstance(input_value, Timestamp):
340                        input_value = str(input_value)
341                    assert str(account_id) == input_value, f"Test failed for valid input: '{input_value}'"
342                except ValueError as e:
343                    assert False, f"Unexpected ValueError for valid input: '{input_value}': {e}"
344            else:
345                try:
346                    cls(input_value)
347                    assert False, f"Expected ValueError for invalid input: '{input_value}'"
348                except ValueError as e:
349                    pass  # Expected exception

Runs tests for the AccountID class to ensure it behaves correctly.

This method tests various valid and invalid input strings to verify that: - Valid AccountIDs are created successfully. - Invalid AccountIDs raise ValueError exceptions.

@dataclasses.dataclass
class AccountDetails:
352@dataclasses.dataclass
353class AccountDetails:
354    """
355    Details of an account.
356
357    Attributes:
358    - account_id: The unique identifier (ID) of the account.
359    - account_name: Human-readable name of the account.
360    - balance: The current cached balance of the account.
361    """
362    account_id: AccountID
363    account_name: str
364    balance: int

Details of an account.

Attributes:

  • account_id: The unique identifier (ID) of the account.
  • account_name: Human-readable name of the account.
  • balance: The current cached balance of the account.
AccountDetails( account_id: AccountID, account_name: str, balance: int)
account_id: AccountID
account_name: str
balance: int
class Timestamp(builtins.int):
188class Timestamp(int):
189    """Represents a timestamp as an integer, which must be greater than zero."""
190
191    def __new__(cls, value):
192        """
193        Creates a new Timestamp instance.
194
195        Parameters:
196        - value (int or str): The integer value to be used as the timestamp.
197
198        Raises:
199        - TypeError: If the provided value is not an integer or a string representing an integer.
200        - ValueError: If the provided value is not greater than zero.
201
202        Returns:
203        - Timestamp: A new Timestamp instance.
204        """
205        if isinstance(value, str):
206            try:
207                value = int(value)
208            except ValueError:
209                raise TypeError(f"String value must represent an integer, instead ({type(value)}: {value}) is given.")
210        if not isinstance(value, int):
211            raise TypeError(f"Timestamp value must be an integer, instead ({type(value)}: {value}) is given.")
212
213        if value <= 0:
214            raise ValueError("Timestamp value must be greater than zero.")
215
216        return super().__new__(cls, value)
217
218    @classmethod
219    def test(cls):
220        """
221        Runs tests for the Timestamp class to ensure it behaves correctly.
222        """
223        test_data = {
224            123: True,
225            "123": True,
226            0: False,
227            "0": False,
228            -1: False,
229            "-1": False,
230            "abc": False,
231            1: True,
232            "1": True,
233        }
234
235        for input_value, expected_output in test_data.items():
236            if expected_output:
237                try:
238                    timestamp = cls(input_value)
239                    assert int(timestamp) == int(input_value), f"Test failed for valid input: '{input_value}'"
240                except (TypeError, ValueError) as e:
241                    assert False, f"Unexpected error for valid input: '{input_value}': {e}"
242            else:
243                try:
244                    cls(input_value)
245                    assert False, f"Expected error for invalid input: '{input_value}'"
246                except (TypeError, ValueError):
247                    pass  # Expected exception

Represents a timestamp as an integer, which must be greater than zero.

Timestamp(value)
191    def __new__(cls, value):
192        """
193        Creates a new Timestamp instance.
194
195        Parameters:
196        - value (int or str): The integer value to be used as the timestamp.
197
198        Raises:
199        - TypeError: If the provided value is not an integer or a string representing an integer.
200        - ValueError: If the provided value is not greater than zero.
201
202        Returns:
203        - Timestamp: A new Timestamp instance.
204        """
205        if isinstance(value, str):
206            try:
207                value = int(value)
208            except ValueError:
209                raise TypeError(f"String value must represent an integer, instead ({type(value)}: {value}) is given.")
210        if not isinstance(value, int):
211            raise TypeError(f"Timestamp value must be an integer, instead ({type(value)}: {value}) is given.")
212
213        if value <= 0:
214            raise ValueError("Timestamp value must be greater than zero.")
215
216        return super().__new__(cls, value)

Creates a new Timestamp instance.

Parameters:

  • value (int or str): The integer value to be used as the timestamp.

Raises:

  • TypeError: If the provided value is not an integer or a string representing an integer.
  • ValueError: If the provided value is not greater than zero.

Returns:

  • Timestamp: A new Timestamp instance.
@classmethod
def test(cls):
218    @classmethod
219    def test(cls):
220        """
221        Runs tests for the Timestamp class to ensure it behaves correctly.
222        """
223        test_data = {
224            123: True,
225            "123": True,
226            0: False,
227            "0": False,
228            -1: False,
229            "-1": False,
230            "abc": False,
231            1: True,
232            "1": True,
233        }
234
235        for input_value, expected_output in test_data.items():
236            if expected_output:
237                try:
238                    timestamp = cls(input_value)
239                    assert int(timestamp) == int(input_value), f"Test failed for valid input: '{input_value}'"
240                except (TypeError, ValueError) as e:
241                    assert False, f"Unexpected error for valid input: '{input_value}': {e}"
242            else:
243                try:
244                    cls(input_value)
245                    assert False, f"Expected error for invalid input: '{input_value}'"
246                except (TypeError, ValueError):
247                    pass  # Expected exception

Runs tests for the Timestamp class to ensure it behaves correctly.

@dataclasses.dataclass
class Box(zakat.StrictDataclass):
460@dataclasses.dataclass
461class Box(
462        StrictDataclass,
463        # ImmutableWithSelectiveFreeze,
464    ):
465    """
466    Represents a financial box with capital, remaining value, and zakat details.
467
468    Attributes:
469    - capital (int): The initial capital value of the box.
470    - rest (int): The current remaining value within the box.
471    - zakat (BoxZakat): A `BoxZakat` object containing the accumulated zakat information for the box.
472    """
473    capital: int #= dataclasses.field(metadata={"frozen": True})
474    rest: int
475    zakat: BoxZakat

Represents a financial box with capital, remaining value, and zakat details.

Attributes:

  • capital (int): The initial capital value of the box.
  • rest (int): The current remaining value within the box.
  • zakat (BoxZakat): A BoxZakat object containing the accumulated zakat information for the box.
Box(capital: int, rest: int, zakat: zakat.zakat_tracker.BoxZakat)
capital: int
rest: int
@dataclasses.dataclass
class Log(zakat.StrictDataclass):
478@dataclasses.dataclass
479class Log(StrictDataclass):
480    """
481    Represents a log entry for an account.
482
483    Attributes:
484    - value: The value of the log entry.
485    - desc: A description of the log entry.
486    - ref: An optional timestamp reference.
487    - file: A dictionary mapping timestamps to file paths.
488    """
489    value: int
490    desc: str
491    ref: Optional[Timestamp]
492    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(zakat.StrictDataclass):
495@dataclasses.dataclass
496class Account(StrictDataclass):
497    """
498    Represents a financial account.
499
500    Attributes:
501    - balance: The current balance of the account.
502    - created: The timestamp when the account was created.
503    - name: The name of the account.
504    - box: A dictionary mapping timestamps to Box objects.
505    - count: A counter for logs, initialized to 0.
506    - log: A dictionary mapping timestamps to Log objects.
507    - hide: A boolean indicating whether the account is hidden.
508    - zakatable: A boolean indicating whether the account is subject to zakat.
509    """
510    balance: int
511    created: Timestamp
512    name: str = ''
513    box: dict[Timestamp, Box] = dataclasses.field(default_factory=dict)
514    count: int = dataclasses.field(default_factory=factory_value(0))
515    log: dict[Timestamp, Log] = dataclasses.field(default_factory=dict)
516    hide: bool = dataclasses.field(default_factory=factory_value(False))
517    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.
  • name: The name of the account.
  • 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, name: str = '', box: dict[Timestamp, Box] = <factory>, count: int = <factory>, log: dict[Timestamp, Log] = <factory>, hide: bool = <factory>, zakatable: bool = <factory>)
balance: int
created: Timestamp
name: str = ''
box: dict[Timestamp, Box]
count: int
log: dict[Timestamp, Log]
hide: bool
zakatable: bool
@dataclasses.dataclass
class Exchange(zakat.StrictDataclass):
520@dataclasses.dataclass
521class Exchange(StrictDataclass):
522    """
523    Represents an exchange rate and related information.
524
525    Attributes:
526    - rate: The exchange rate (optional).
527    - description: A description of the exchange (optional).
528    - time: The timestamp of the exchange (optional).
529    """
530    rate: Optional[float] = None
531    description: Optional[str] = None
532    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(zakat.StrictDataclass):
535@dataclasses.dataclass
536class History(StrictDataclass):
537    """
538    Represents a history entry for an account action.
539
540    Attributes:
541    - action: The action performed.
542    - account: The ID of the account (optional).
543    - ref: An optional timestamp reference.
544    - file: An optional timestamp for a file.
545    - key: An optional key.
546    - value: An optional value.
547    - math: An optional math operation.
548    """
549    action: Action
550    account: Optional[AccountID]
551    ref: Optional[Timestamp]
552    file: Optional[Timestamp]
553    key: Optional[str]
554    value: Optional[any] # !!!
555    math: Optional[MathOperation]

Represents a history entry for an account action.

Attributes:

  • action: The action performed.
  • account: The ID 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[AccountID], ref: Optional[Timestamp], file: Optional[Timestamp], key: Optional[str], value: Optional[<built-in function any>], math: Optional[MathOperation])
action: Action
account: Optional[AccountID]
ref: Optional[Timestamp]
file: Optional[Timestamp]
key: Optional[str]
value: Optional[<built-in function any>]
math: Optional[MathOperation]
@dataclasses.dataclass
class Vault(zakat.StrictDataclass):
630@dataclasses.dataclass
631class Vault(StrictDataclass):
632    """
633    Represents a vault containing accounts, exchanges, and history.
634
635    Attributes:
636    - account: A dictionary mapping account IDs to Account objects.
637    - exchange: A dictionary mapping account IDs to dictionaries of timestamps and Exchange objects.
638    - history: A dictionary mapping timestamps to dictionaries of History objects.
639    - lock: An optional timestamp for a lock.
640    - report: A dictionary mapping timestamps to tuples.
641    - cache: A Cache object containing cached Zakat-related data.
642    """
643    account: dict[AccountID, Account] = dataclasses.field(default_factory=dict)
644    exchange: dict[AccountID, dict[Timestamp, Exchange]] = dataclasses.field(default_factory=dict)
645    history: dict[Timestamp, dict[Timestamp, History]] = dataclasses.field(default_factory=dict)
646    lock: Optional[Timestamp] = None
647    report: dict[Timestamp, ZakatReport] = dataclasses.field(default_factory=dict)
648    cache: Cache = dataclasses.field(default_factory=Cache)

Represents a vault containing accounts, exchanges, and history.

Attributes:

  • account: A dictionary mapping account IDs to Account objects.
  • exchange: A dictionary mapping account IDs to dictionaries of timestamps and Exchange objects.
  • history: A dictionary mapping timestamps to dictionaries of History objects.
  • lock: An optional timestamp for a lock.
  • report: A dictionary mapping timestamps to tuples.
  • cache: A Cache object containing cached Zakat-related data.
Vault( account: dict[AccountID, Account] = <factory>, exchange: dict[AccountID, dict[Timestamp, Exchange]] = <factory>, history: dict[Timestamp, dict[Timestamp, History]] = <factory>, lock: Optional[Timestamp] = None, report: dict[Timestamp, ZakatReport] = <factory>, cache: zakat.zakat_tracker.Cache = <factory>)
account: dict[AccountID, Account]
exchange: dict[AccountID, dict[Timestamp, Exchange]]
history: dict[Timestamp, dict[Timestamp, History]]
lock: Optional[Timestamp] = None
report: dict[Timestamp, ZakatReport]
@dataclasses.dataclass
class AccountPaymentPart(zakat.StrictDataclass):
651@dataclasses.dataclass
652class AccountPaymentPart(StrictDataclass):
653    """
654    Represents a payment part for an account.
655
656    Attributes:
657    - balance: The balance of the payment part.
658    - rate: The rate of the payment part.
659    - part: The part of the payment.
660    """
661    balance: float
662    rate: float
663    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(zakat.StrictDataclass):
666@dataclasses.dataclass
667class PaymentParts(StrictDataclass):
668    """
669    Represents payment parts for multiple accounts.
670
671    Attributes:
672    - exceed: A boolean indicating whether the payment exceeds a limit.
673    - demand: The demand for payment.
674    - total: The total payment.
675    - account: A dictionary mapping account references to AccountPaymentPart objects.
676    """
677    exceed: bool
678    demand: int
679    total: float
680    account: dict[AccountID, 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 references to AccountPaymentPart objects.
PaymentParts( exceed: bool, demand: int, total: float, account: dict[AccountID, AccountPaymentPart] = <factory>)
exceed: bool
demand: int
total: float
account: dict[AccountID, AccountPaymentPart]
@dataclasses.dataclass
class SubtractAge(zakat.StrictDataclass):
683@dataclasses.dataclass
684class SubtractAge(StrictDataclass):
685    """
686    Represents an age subtraction.
687
688    Attributes:
689    - box_ref: The timestamp reference for the box.
690    - total: The total amount to subtract.
691    """
692    box_ref: Timestamp
693    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
@dataclasses.dataclass
class SubtractAges(zakat.StrictDataclass, list[zakat.zakat_tracker.SubtractAge]):
696@dataclasses.dataclass
697class SubtractAges(StrictDataclass, list[SubtractAge]):
698    """A list of SubtractAge objects."""
699    pass

A list of SubtractAge objects.

@dataclasses.dataclass
class SubtractReport(zakat.StrictDataclass):
702@dataclasses.dataclass
703class SubtractReport(StrictDataclass):
704    """
705    Represents a report of age subtractions.
706
707    Attributes:
708    - log_ref: The timestamp reference for the log.
709    - ages: A list of SubtractAge objects.
710    """
711    log_ref: Timestamp
712    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(zakat.StrictDataclass):
715@dataclasses.dataclass
716class TransferTime(StrictDataclass):
717    """
718    Represents a transfer time.
719
720    Attributes:
721    - box_ref: The timestamp reference for the box.
722    - log_ref: The timestamp reference for the log.
723    """
724    box_ref: Timestamp
725    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
@dataclasses.dataclass
class TransferTimes(zakat.StrictDataclass, list[zakat.zakat_tracker.TransferTime]):
728@dataclasses.dataclass
729class TransferTimes(StrictDataclass, list[TransferTime]):
730    """A list of TransferTime objects."""
731    pass

A list of TransferTime objects.

@dataclasses.dataclass
class TransferRecord(zakat.StrictDataclass):
734@dataclasses.dataclass
735class TransferRecord(StrictDataclass):
736    """
737    Represents a transfer record.
738
739    Attributes:
740    - box_ref: The timestamp reference for the box.
741    - times: A list of TransferTime objects.
742    """
743    box_ref: Timestamp
744    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(zakat.StrictDataclass, list[zakat.zakat_tracker.TransferRecord]):
747class TransferReport(StrictDataclass, list[TransferRecord]):
748    """A list of TransferRecord objects."""
749    pass

A list of TransferRecord objects.

@dataclasses.dataclass
class BoxPlan(zakat.StrictDataclass):
558@dataclasses.dataclass
559class BoxPlan(StrictDataclass):
560    """
561    Represents a plan for a box.
562
563    Attributes:
564    - box: The Box object.
565    - log: The Log object.
566    - exchange: The Exchange object.
567    - below_nisab: A boolean indicating whether the value is below nisab.
568    - total: The total value.
569    - count: The count.
570    - ref: The timestamp reference for related Box & Log.
571    """
572    box: Box
573    log: Log
574    exchange: Exchange
575    below_nisab: bool
576    total: float
577    count: int
578    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 for related Box & Log.
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
@dataclasses.dataclass
class ZakatSummary(zakat.StrictDataclass):
581@dataclasses.dataclass
582class ZakatSummary(StrictDataclass):
583    """
584    Summarizes key financial figures for a Zakat calculation.
585
586    Attributes:
587    - total_wealth (int): The total wealth collected from all rest of transactions.
588    - num_wealth_items (int): The number of individual transactions contributing to the total wealth.
589    - num_zakatable_items (int): The number of transactions subject to Zakat.
590    - total_zakatable_amount (int): The total value of all transactions subject to Zakat.
591    - total_zakat_due (int): The calculated amount of Zakat payable.
592    """
593    total_wealth: int = 0
594    num_wealth_items: int = 0
595    num_zakatable_items: int = 0
596    total_zakatable_amount: int = 0
597    total_zakat_due: int = 0

Summarizes key financial figures for a Zakat calculation.

Attributes:

  • total_wealth (int): The total wealth collected from all rest of transactions.
  • num_wealth_items (int): The number of individual transactions contributing to the total wealth.
  • num_zakatable_items (int): The number of transactions subject to Zakat.
  • total_zakatable_amount (int): The total value of all transactions subject to Zakat.
  • total_zakat_due (int): The calculated amount of Zakat payable.
ZakatSummary( total_wealth: int = 0, num_wealth_items: int = 0, num_zakatable_items: int = 0, total_zakatable_amount: int = 0, total_zakat_due: int = 0)
total_wealth: int = 0
num_wealth_items: int = 0
num_zakatable_items: int = 0
total_zakatable_amount: int = 0
total_zakat_due: int = 0
@dataclasses.dataclass
class ZakatReport(zakat.StrictDataclass):
600@dataclasses.dataclass
601class ZakatReport(StrictDataclass):
602    """
603    Represents a Zakat report containing the calculation summary, plan, and parameters.
604
605    Attributes:
606    - created: The timestamp when the report was created.
607    - valid: A boolean indicating whether the Zakat is available.
608    - summary: The ZakatSummary object.
609    - plan: A dictionary mapping account IDs to lists of BoxPlan objects.
610    - parameters: A dictionary holding the input parameters used during the Zakat calculation.
611    """
612    created: Timestamp
613    valid: bool
614    summary: ZakatSummary
615    plan: dict[AccountID, list[BoxPlan]]
616    parameters: dict

Represents a Zakat report containing the calculation summary, plan, and parameters.

Attributes:

  • created: The timestamp when the report was created.
  • valid: A boolean indicating whether the Zakat is available.
  • summary: The ZakatSummary object.
  • plan: A dictionary mapping account IDs to lists of BoxPlan objects.
  • parameters: A dictionary holding the input parameters used during the Zakat calculation.
ZakatReport( created: Timestamp, valid: bool, summary: ZakatSummary, plan: dict[AccountID, list[BoxPlan]], parameters: dict)
created: Timestamp
valid: bool
summary: ZakatSummary
plan: dict[AccountID, list[BoxPlan]]
parameters: dict
def test(path: Optional[str] = None, debug: bool = False):
5570def test(path: Optional[str] = None, debug: bool = False):
5571    """
5572    Executes a test suite for the ZakatTracker.
5573
5574    This function initializes a ZakatTracker instance, optionally using a specified
5575    database path or a temporary directory. It then runs the test suite and, if debug
5576    mode is enabled, prints detailed test results and execution time.
5577
5578    Parameters:
5579    - path (str, optional): The path to the ZakatTracker database. If None, a
5580                            temporary directory is created. Defaults to None.
5581    - debug (bool, optional): Enables debug mode, which prints detailed test
5582                            results and execution time. Defaults to False.
5583
5584    Returns:
5585    None. The function asserts the result of the ZakatTracker's test suite.
5586
5587    Raises:
5588    - AssertionError: If the ZakatTracker's test suite fails.
5589
5590    Examples:
5591    - `test()` Runs tests using a temporary database.
5592    - `test(debug=True)` Runs the test suite in debug mode with a temporary directory.
5593    - `test(path="/path/to/my/db")` Runs tests using a specified database path.
5594    - `test(path="/path/to/my/db", debug=False)` Runs test suite with specified path.
5595    """
5596    no_path = path is None
5597    if no_path:
5598        path = tempfile.mkdtemp()
5599        print(f"Random database path {path}")
5600    if os.path.exists(path):
5601        shutil.rmtree(path)
5602    assert ZakatTracker(':memory:').memory_mode()
5603    ledger = ZakatTracker(
5604        db_path=path,
5605        history_mode=True,
5606    )
5607    start = time.time_ns()
5608    assert not ledger.memory_mode()
5609    assert ledger.test(debug=debug)
5610    if no_path and os.path.exists(path):
5611        shutil.rmtree(path)
5612    if debug:
5613        print('#########################')
5614        print('######## TEST DONE ########')
5615        print('#########################')
5616        print(Time.duration_from_nanoseconds(time.time_ns() - start))
5617        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):
124@enum.unique
125class Action(enum.Enum):
126    """
127    Enumeration representing various actions that can be performed.
128
129    Members:
130    - CREATE: Represents the creation action ('CREATE').
131    - NAME: Represents the renaming action ('NAME').
132    - TRACK: Represents the tracking action ('TRACK').
133    - LOG: Represents the logging action ('LOG').
134    - SUBTRACT: Represents the subtract action ('SUBTRACT').
135    - ADD_FILE: Represents the action of adding a file ('ADD_FILE').
136    - REMOVE_FILE: Represents the action of removing a file ('REMOVE_FILE').
137    - BOX_TRANSFER: Represents the action of transferring a box ('BOX_TRANSFER').
138    - EXCHANGE: Represents the exchange action ('EXCHANGE').
139    - REPORT: Represents the reporting action ('REPORT').
140    - ZAKAT: Represents a Zakat related action ('ZAKAT').
141    """
142    CREATE = 'CREATE'
143    NAME = 'NAME'
144    TRACK = 'TRACK'
145    LOG = 'LOG'
146    SUBTRACT = 'SUBTRACT'
147    ADD_FILE = 'ADD_FILE'
148    REMOVE_FILE = 'REMOVE_FILE'
149    BOX_TRANSFER = 'BOX_TRANSFER'
150    EXCHANGE = 'EXCHANGE'
151    REPORT = 'REPORT'
152    ZAKAT = 'ZAKAT'

Enumeration representing various actions that can be performed.

Members:

  • CREATE: Represents the creation action ('CREATE').
  • NAME: Represents the renaming action ('NAME').
  • 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'>
NAME = <Action.NAME: 'NAME'>
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):
901class JSONEncoder(json.JSONEncoder):
902    """
903    Custom JSON encoder to handle specific object types.
904
905    This encoder overrides the default `default` method to serialize:
906    - `Action` and `MathOperation` enums as their member names.
907    - `decimal.Decimal` instances as floats.
908
909    Example:
910    ```bash
911    >>> json.dumps(Action.CREATE, cls=JSONEncoder)
912    'CREATE'
913    >>> json.dumps(decimal.Decimal('10.5'), cls=JSONEncoder)
914    '10.5'
915    ```
916    """
917    def default(self, o):
918        """
919        Overrides the default `default` method to serialize specific object types.
920
921        Parameters:
922        - o: The object to serialize.
923
924        Returns:
925        - The serialized object.
926        """
927        if isinstance(o, (Action, MathOperation)):
928            return o.name  # Serialize as the enum member's name
929        if isinstance(o, decimal.Decimal):
930            return float(o)
931        if isinstance(o, Exception):
932            return str(o)
933        if isinstance(o, Vault) or isinstance(o, ImportReport):
934            return dataclasses.asdict(o)
935        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):
917    def default(self, o):
918        """
919        Overrides the default `default` method to serialize specific object types.
920
921        Parameters:
922        - o: The object to serialize.
923
924        Returns:
925        - The serialized object.
926        """
927        if isinstance(o, (Action, MathOperation)):
928            return o.name  # Serialize as the enum member's name
929        if isinstance(o, decimal.Decimal):
930            return float(o)
931        if isinstance(o, Exception):
932            return str(o)
933        if isinstance(o, Vault) or isinstance(o, ImportReport):
934            return dataclasses.asdict(o)
935        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):
938class JSONDecoder(json.JSONDecoder):
939    """
940    Custom JSON decoder to handle specific object types.
941
942    This decoder overrides the `object_hook` method to deserialize:
943    - Strings representing enum member names back to their respective enum values.
944    - Floats back to `decimal.Decimal` instances.
945
946    Example:
947    ```bash
948    >>> json.loads('{"action": "CREATE"}', cls=JSONDecoder)
949    {'action': <Action.CREATE: 1>}
950    >>> json.loads('{"value": 10.5}', cls=JSONDecoder)
951    {'value': Decimal('10.5')}
952    ```
953    """
954    def object_hook(self, obj):
955        """
956        Overrides the default `object_hook` method to deserialize specific object types.
957
958        Parameters:
959        - obj: The object to deserialize.
960
961        Returns:
962        - The deserialized object.
963        """
964        if isinstance(obj, str) and obj in Action.__members__:
965            return Action[obj]
966        if isinstance(obj, str) and obj in MathOperation.__members__:
967            return MathOperation[obj]
968        if isinstance(obj, float):
969            return decimal.Decimal(str(obj))
970        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):
954    def object_hook(self, obj):
955        """
956        Overrides the default `object_hook` method to deserialize specific object types.
957
958        Parameters:
959        - obj: The object to deserialize.
960
961        Returns:
962        - The deserialized object.
963        """
964        if isinstance(obj, str) and obj in Action.__members__:
965            return Action[obj]
966        if isinstance(obj, str) and obj in MathOperation.__members__:
967            return MathOperation[obj]
968        if isinstance(obj, float):
969            return decimal.Decimal(str(obj))
970        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):
155@enum.unique
156class MathOperation(enum.Enum):
157    """
158    Enumeration representing mathematical operations.
159
160    Members:
161    - ADDITION: Represents the addition operation ('ADDITION').
162    - EQUAL: Represents the equality operation ('EQUAL').
163    - SUBTRACTION: Represents the subtraction operation ('SUBTRACTION').
164    """
165    ADDITION = 'ADDITION'
166    EQUAL = 'EQUAL'
167    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):
101@enum.unique
102class WeekDay(enum.Enum):
103    """
104    Enumeration representing the days of the week.
105
106    Members:
107    - MONDAY: Represents Monday (0).
108    - TUESDAY: Represents Tuesday (1).
109    - WEDNESDAY: Represents Wednesday (2).
110    - THURSDAY: Represents Thursday (3).
111    - FRIDAY: Represents Friday (4).
112    - SATURDAY: Represents Saturday (5).
113    - SUNDAY: Represents Sunday (6).
114    """
115    MONDAY = 0
116    TUESDAY = 1
117    WEDNESDAY = 2
118    THURSDAY = 3
119    FRIDAY = 4
120    SATURDAY = 5
121    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:
112def start_file_server(database_path: str, database_callback: Optional[callable] = None, csv_callback: Optional[callable] = None,
113                      debug: bool = False) -> tuple:
114    """
115    Starts a multi-purpose WSGI server to manage file interactions for a Zakat application.
116
117    This server facilitates the following functionalities:
118
119    1. GET `/{file_uuid}/get`: Download the database file specified by `database_path`.
120    2. GET `/{file_uuid}/upload`: Display an HTML form for uploading files.
121    3. POST `/{file_uuid}/upload`: Handle file uploads, distinguishing between:
122        - Database File (.db): Replaces the existing database with the uploaded one.
123        - CSV File (.csv): Imports data from the CSV into the existing database.
124
125    Parameters:
126    - database_path (str): The path to the camel database file.
127    - database_callback (callable, optional): A function to call after a successful database upload.
128                                                It receives the uploaded database path as its argument.
129    - csv_callback (callable, optional): A function to call after a successful CSV upload. It receives the uploaded CSV path,
130                                           the database path, and the debug flag as its arguments.
131    - debug (bool, optional): If True, print debugging information. Defaults to False.
132
133    Returns:
134    - Tuple[str, str, str, threading.Thread, Callable[[], None]]: A tuple containing:
135        - file_name (str): The name of the database file.
136        - download_url (str): The URL to download the database file.
137        - upload_url (str): The URL to access the file upload form.
138        - server_thread (threading.Thread): The thread running the server.
139        - shutdown_server (Callable[[], None]): A function to gracefully shut down the server.
140
141    Example:
142    ```python
143    _, download_url, upload_url, server_thread, shutdown_server = start_file_server("zakat.db")
144    print(f"Download database: {download_url}")
145    print(f"Upload files: {upload_url}")
146    server_thread.start()
147    # ... later ...
148    shutdown_server()
149    ```
150    """
151    file_uuid = uuid.uuid4()
152    file_name = os.path.basename(database_path)
153
154    port = find_available_port()
155    download_url = f"http://localhost:{port}/{file_uuid}/get"
156    upload_url = f"http://localhost:{port}/{file_uuid}/upload"
157
158    # Upload directory
159    upload_directory = "./uploads"
160    os.makedirs(upload_directory, exist_ok=True)
161
162    # HTML templates
163    upload_form = f"""
164    <html lang="en">
165        <head>
166            <title>Zakat File Server</title>
167        </head>
168    <body>
169    <h1>Zakat File Server</h1>
170    <h3>You can download the <a target="__blank" href="{download_url}">database file</a>...</h3>
171    <h3>Or upload a new file to restore a database or import `CSV` file:</h3>
172    <form action="/{file_uuid}/upload" method="post" enctype="multipart/form-data">
173        <input type="file" name="file" required><br/>
174        <input type="radio" id="{FileType.Database.value}" name="upload_type" value="{FileType.Database.value}" required>
175        <label for="database">Database File</label><br/>
176        <input type="radio"id="{FileType.CSV.value}" name="upload_type" value="{FileType.CSV.value}">
177        <label for="csv">CSV File</label><br/>
178        <input type="submit" value="Upload"><br/>
179    </form>
180    </body></html>
181    """
182
183    # WSGI application
184    def wsgi_app(environ, start_response):
185        path = environ.get('PATH_INFO', '')
186        method = environ.get('REQUEST_METHOD', 'GET')
187
188        if path == f"/{file_uuid}/get" and method == 'GET':
189            # GET: Serve the existing file
190            try:
191                with open(database_path, "rb") as f:
192                    file_content = f.read()
193                    
194                start_response('200 OK', [
195                    ('Content-type', 'application/octet-stream'),
196                    ('Content-Disposition', f'attachment; filename="{file_name}"'),
197                    ('Content-Length', str(len(file_content)))
198                ])
199                return [file_content]
200            except FileNotFoundError:
201                start_response('404 Not Found', [('Content-type', 'text/plain')])
202                return [b'File not found']
203                
204        elif path == f"/{file_uuid}/upload" and method == 'GET':
205            # GET: Serve the upload form
206            start_response('200 OK', [('Content-type', 'text/html')])
207            return [upload_form.encode()]
208            
209        elif path == f"/{file_uuid}/upload" and method == 'POST':
210            # POST: Handle file uploads
211            try:
212                # Get content length
213                content_length = int(environ.get('CONTENT_LENGTH', 0))
214                
215                # Get content type and boundary
216                content_type = environ.get('CONTENT_TYPE', '')
217                
218                # Read the request body
219                request_body = environ['wsgi.input'].read(content_length)
220                
221                # Create a file-like object from the request body
222                # request_body_file = io.BytesIO(request_body)
223                
224                # Parse the multipart form data using WSGI approach
225                # First, detect the boundary from content_type
226                boundary = None
227                for part in content_type.split(';'):
228                    part = part.strip()
229                    if part.startswith('boundary='):
230                        boundary = part[9:]
231                        if boundary.startswith('"') and boundary.endswith('"'):
232                            boundary = boundary[1:-1]
233                        break
234                
235                if not boundary:
236                    start_response('400 Bad Request', [('Content-type', 'text/plain')])
237                    return [b"Missing boundary in multipart form data"]
238                
239                # Process multipart data
240                parts = request_body.split(f'--{boundary}'.encode())
241                
242                # Initialize variables to store form data
243                upload_type = None
244                # file_item = None
245                file_data = None
246                filename = None
247                
248                # Process each part
249                for part in parts:
250                    if not part.strip():
251                        continue
252                    
253                    # Split header and body
254                    try:
255                        headers_raw, body = part.split(b'\r\n\r\n', 1)
256                        headers_text = headers_raw.decode('utf-8')
257                    except ValueError:
258                        continue
259                    
260                    # Parse headers
261                    headers = {}
262                    for header_line in headers_text.split('\r\n'):
263                        if ':' in header_line:
264                            name, value = header_line.split(':', 1)
265                            headers[name.strip().lower()] = value.strip()
266                    
267                    # Get content disposition
268                    content_disposition = headers.get('content-disposition', '')
269                    if not content_disposition.startswith('form-data'):
270                        continue
271                    
272                    # Extract field name
273                    field_name = None
274                    for item in content_disposition.split(';'):
275                        item = item.strip()
276                        if item.startswith('name='):
277                            field_name = item[5:].strip('"\'')
278                            break
279                    
280                    if not field_name:
281                        continue
282                    
283                    # Handle upload_type field
284                    if field_name == 'upload_type':
285                        # Remove trailing data including the boundary
286                        body_end = body.find(b'\r\n--')
287                        if body_end >= 0:
288                            body = body[:body_end]
289                        upload_type = body.decode('utf-8').strip()
290                    
291                    # Handle file field
292                    elif field_name == 'file':
293                        # Extract filename
294                        for item in content_disposition.split(';'):
295                            item = item.strip()
296                            if item.startswith('filename='):
297                                filename = item[9:].strip('"\'')
298                                break
299                        
300                        if filename:
301                            # Remove trailing data including the boundary
302                            body_end = body.find(b'\r\n--')
303                            if body_end >= 0:
304                                body = body[:body_end]
305                            file_data = body
306                
307                if debug:
308                    print('upload_type', upload_type)
309                    
310                if debug:
311                    print('upload_type:', upload_type)
312                    print('filename:', filename)
313                
314                if not upload_type or upload_type not in [FileType.Database.value, FileType.CSV.value]:
315                    start_response('400 Bad Request', [('Content-type', 'text/plain')])
316                    return [b"Invalid upload type"]
317                
318                if not filename or not file_data:
319                    start_response('400 Bad Request', [('Content-type', 'text/plain')])
320                    return [b"Missing file data"]
321                
322                if debug:
323                    print(f'Uploaded filename: {filename}')
324                
325                # Save the file
326                file_path = os.path.join(upload_directory, upload_type)
327                with open(file_path, 'wb') as f:
328                    f.write(file_data)
329                
330                # Process based on file type
331                if upload_type == FileType.Database.value:
332                    try:
333                        # Verify database file
334                        if database_callback is not None:
335                            database_callback(file_path)
336                        
337                        # Copy database into the original path
338                        shutil.copy2(file_path, database_path)
339                        
340                        start_response('200 OK', [('Content-type', 'text/plain')])
341                        return [b"Database file uploaded successfully."]
342                    except Exception as e:
343                        start_response('400 Bad Request', [('Content-type', 'text/plain')])
344                        return [str(e).encode()]
345                
346                elif upload_type == FileType.CSV.value:
347                    try:
348                        if csv_callback is not None:
349                            result = csv_callback(file_path, database_path, debug)
350                            if debug:
351                                print(f'CSV imported: {result}')
352                            if len(result[2]) != 0:
353                                start_response('200 OK', [('Content-type', 'application/json')])
354                                return [json.dumps(result).encode()]
355                        
356                        start_response('200 OK', [('Content-type', 'text/plain')])
357                        return [b"CSV file uploaded successfully."]
358                    except Exception as e:
359                        start_response('400 Bad Request', [('Content-type', 'text/plain')])
360                        return [str(e).encode()]
361            
362            except Exception as e:
363                start_response('500 Internal Server Error', [('Content-type', 'text/plain')])
364                return [f"Error processing upload: {str(e)}".encode()]
365        
366        else:
367            # 404 for anything else
368            start_response('404 Not Found', [('Content-type', 'text/plain')])
369            return [b'Not Found']
370    
371    # Create and start the server
372    httpd = make_server('localhost', port, wsgi_app)
373    server_thread = threading.Thread(target=httpd.serve_forever)
374    
375    def shutdown_server():
376        nonlocal httpd, server_thread
377        httpd.shutdown()
378        server_thread.join()  # Wait for the thread to finish
379    
380    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:
 87def find_available_port() -> int:
 88    """
 89    Finds and returns an available TCP port on the local machine.
 90
 91    This function utilizes a TCP server socket to bind to port 0, which
 92    instructs the operating system to automatically assign an available
 93    port. The assigned port is then extracted and returned.
 94
 95    Returns:
 96    - int: The available TCP port number.
 97
 98    Raises:
 99    - OSError: If an error occurs during the port binding process, such
100            as all ports being in use.
101
102    Example:
103    ```python
104    port = find_available_port()
105    print(f"Available port: {port}")
106    ```
107    """
108    with socketserver.TCPServer(("localhost", 0), None) as s:
109        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'>
@dataclasses.dataclass
class StrictDataclass:
374@dataclasses.dataclass
375class StrictDataclass:
376    """A dataclass that prevents setting non-existent attributes."""
377    def __setattr__(self, name: str, value: any) -> None:
378        _check_attribute(self, name, value)

A dataclass that prevents setting non-existent attributes.

class ImmutableWithSelectiveFreeze:
381class ImmutableWithSelectiveFreeze:
382    """
383    A base class for creating immutable objects with the ability to selectively
384    freeze specific fields.
385
386    Inheriting from this class will automatically make all fields defined in
387    dataclasses as frozen after initialization if their metadata contains
388    `"frozen": True`. Attempting to set a value to a frozen field after
389    initialization will raise a RuntimeError.
390
391    Example:
392    ```python
393    @dataclasses.dataclass
394    class MyObject(ImmutableWithSelectiveFreeze):
395        name: str
396        count: int = dataclasses.field(metadata={"frozen": True})
397        description: str = "default"
398
399    obj = MyObject(name="Test", count=5)
400    print(obj.name)  # Output: Test
401    print(obj.count) # Output: 5
402    obj.name = "New Name" # This will work
403    try:
404        obj.count = 10  # This will raise a RuntimeError
405    except RuntimeError as e:
406        print(e)      # Output: Field 'count' is frozen!
407    print(obj.description) # Output: default
408    obj.description = "updated" # This will work
409    ```
410    """
411    # Implementation based on: https://discuss.python.org/t/dataclasses-freezing-specific-fields-should-be-possible/59968/2
412    def __post_init__(self):
413        """
414        Initializes the object and freezes fields marked with `"frozen": True`
415        in their metadata.
416        """
417        self.__set_fields_frozen(self)
418
419    @classmethod
420    def __set_fields_frozen(cls, self):
421        """
422        Iterates through the dataclass fields and freezes those with the
423        `"frozen": True` metadata.
424        """
425        flds = dataclasses.fields(cls)
426        for fld in flds:
427            if fld.metadata.get("frozen"):
428                field_name = fld.name
429                field_value = getattr(self, fld.name)
430                setattr(self, f"_{fld.name}", field_value)
431
432                def local_getter(self):
433                    """Getter for the frozen field."""
434                    return getattr(self, f"_{field_name}")
435
436                def frozen(name):
437                    """Creates a setter that raises a RuntimeError for frozen fields."""
438                    def local_setter(self, value):
439                        raise RuntimeError(f"Field '{name}' is frozen!")
440                    return local_setter
441
442                setattr(cls, field_name, property(local_getter, frozen(field_name)))

A base class for creating immutable objects with the ability to selectively freeze specific fields.

Inheriting from this class will automatically make all fields defined in dataclasses as frozen after initialization if their metadata contains "frozen": True. Attempting to set a value to a frozen field after initialization will raise a RuntimeError.

Example:

@dataclasses.dataclass
class MyObject(ImmutableWithSelectiveFreeze):
    name: str
    count: int = dataclasses.field(metadata={"frozen": True})
    description: str = "default"

obj = MyObject(name="Test", count=5)
print(obj.name)  # Output: Test
print(obj.count) # Output: 5
obj.name = "New Name" # This will work
try:
    obj.count = 10  # This will raise a RuntimeError
except RuntimeError as e:
    print(e)      # Output: Field 'count' is frozen!
print(obj.description) # Output: default
obj.description = "updated" # This will work
@dataclasses.dataclass
class Backup:
888@dataclasses.dataclass
889class Backup:
890    """
891    Represents a backup of a file.
892
893    Attributes:
894    - path (str): The path to the back-up file.
895    - hash (str): The hash (SHA1) of the backed-up data for integrity verification.
896    """
897    path: str
898    hash: str

Represents a backup of a file.

Attributes:

  • path (str): The path to the back-up file.
  • hash (str): The hash (SHA1) of the backed-up data for integrity verification.
Backup(path: str, hash: str)
path: str
hash: str