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    Collection,
 62)
 63
 64from zakat.file_server import (
 65    start_file_server,
 66    find_available_port,
 67    FileType,
 68)
 69
 70# Shortcuts
 71time = Time.time
 72time_to_datetime = Time.time_to_datetime
 73tracker = ZakatTracker
 74
 75# Version information for the module
 76__version__ = ZakatTracker.Version()
 77__all__ = [
 78    "Time",
 79    "time",
 80    "time_to_datetime",
 81    "tracker",
 82    "SizeInfo",
 83    "FileInfo",
 84    "FileStats",
 85    "TimeSummary",
 86    "Transaction",
 87    "DailyRecords",
 88    "Timeline",
 89    "ImportStatistics",
 90    "CSVRecord",
 91    "ImportReport",
 92    "ZakatTracker",
 93    "AccountID",
 94    "AccountDetails",
 95    "Timestamp",
 96    "Box",
 97    "Log",
 98    "Account",
 99    "Exchange",
100    "History",
101    "Vault",
102    "AccountPaymentPart",
103    "PaymentParts",
104    "SubtractAge",
105    "SubtractAges",
106    "SubtractReport",
107    "TransferTime",
108    "TransferTimes",
109    "TransferRecord",
110    "TransferReport",
111    "BoxPlan",
112    "ZakatSummary",
113    "ZakatReport",
114    "test",
115    "Action",
116    "JSONEncoder",
117    "JSONDecoder",
118    "MathOperation",
119    "WeekDay",
120    "start_file_server",
121    "find_available_port",
122    "FileType",
123    "StrictDataclass",
124    "ImmutableWithSelectiveFreeze",
125    "Backup",
126    "Collection",
127]
class Time:
1126class Time:
1127    """
1128    Utility class for generating and manipulating nanosecond-precision timestamps.
1129
1130    This class provides static methods for converting between datetime objects and
1131    nanosecond-precision timestamps, ensuring uniqueness and monotonicity.
1132    """
1133    __last_time_ns = None
1134    __time_diff_ns = None
1135
1136    @staticmethod
1137    def minimum_time_diff_ns() -> tuple[int, int]:
1138        """
1139        Calculates the minimum time difference between two consecutive calls to
1140        `Time._time()` in nanoseconds.
1141
1142        This method is used internally to determine the minimum granularity of
1143        time measurements within the system.
1144
1145        Returns:
1146        - tuple[int, int]:
1147            - The minimum time difference in nanoseconds.
1148            - The number of iterations required to measure the difference.
1149        """
1150        i = 0
1151        x = y = Time._time()
1152        while x == y:
1153            y = Time._time()
1154            i += 1
1155        return y - x, i
1156
1157    @staticmethod
1158    def _time(now: Optional[datetime.datetime] = None) -> Timestamp:
1159        """
1160        Internal method to generate a nanosecond-precision timestamp from a datetime object.
1161
1162        Parameters:
1163        - now (datetime.datetime, optional): The datetime object to generate the timestamp from.
1164        If not provided, the current datetime is used.
1165
1166        Returns:
1167        - int: The timestamp in nanoseconds since the epoch (January 1, 1AD).
1168        """
1169        if now is None:
1170            now = datetime.datetime.now()
1171        ns_in_day = (now - now.replace(
1172            hour=0,
1173            minute=0,
1174            second=0,
1175            microsecond=0,
1176        )).total_seconds() * 10 ** 9
1177        return Timestamp(int(now.toordinal() * 86_400_000_000_000 + ns_in_day))
1178
1179    @staticmethod
1180    def time(now: Optional[datetime.datetime] = None) -> Timestamp:
1181        """
1182        Generates a unique, monotonically increasing timestamp based on the provided
1183        datetime object or the current datetime.
1184
1185        This method ensures that timestamps are unique even if called in rapid succession
1186        by introducing a small delay if necessary, based on the system's minimum
1187        time resolution.
1188
1189        Parameters:
1190        - now (datetime.datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used.
1191
1192        Returns:
1193        - Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD).
1194        """
1195        new_time = Time._time(now)
1196        if Time.__last_time_ns is None:
1197            Time.__last_time_ns = new_time
1198            return new_time
1199        while new_time == Time.__last_time_ns:
1200            if Time.__time_diff_ns is None:
1201                diff, _ = Time.minimum_time_diff_ns()
1202                Time.__time_diff_ns = math.ceil(diff)
1203            time.sleep(Time.__time_diff_ns / 1_000_000_000)
1204            new_time = Time._time()
1205        Time.__last_time_ns = new_time
1206        return new_time
1207
1208    @staticmethod
1209    def time_to_datetime(ordinal_ns: Timestamp) -> datetime.datetime:
1210        """
1211        Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD)
1212        back to a datetime object.
1213
1214        Parameters:
1215        - ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD).
1216
1217        Returns:
1218        - datetime.datetime: The corresponding datetime object.
1219        """
1220        d = datetime.datetime.fromordinal(ordinal_ns // 86_400_000_000_000)
1221        t = datetime.timedelta(seconds=(ordinal_ns % 86_400_000_000_000) // 10 ** 9)
1222        return datetime.datetime.combine(d, datetime.time()) + t
1223
1224    @staticmethod
1225    def duration_from_nanoseconds(ns: int,
1226                                  show_zeros_in_spoken_time: bool = False,
1227                                  spoken_time_separator=',',
1228                                  millennia: str = 'Millennia',
1229                                  century: str = 'Century',
1230                                  years: str = 'Years',
1231                                  days: str = 'Days',
1232                                  hours: str = 'Hours',
1233                                  minutes: str = 'Minutes',
1234                                  seconds: str = 'Seconds',
1235                                  milli_seconds: str = 'MilliSeconds',
1236                                  micro_seconds: str = 'MicroSeconds',
1237                                  nano_seconds: str = 'NanoSeconds',
1238                                  ) -> tuple:
1239        """
1240        REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
1241        Convert NanoSeconds to Human Readable Time Format.
1242        A NanoSeconds is a unit of time in the International System of Units (SI) equal
1243        to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
1244        Its symbol is μs, sometimes simplified to us when Unicode is not available.
1245        A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
1246
1247        INPUT : ms (AKA: MilliSeconds)
1248        OUTPUT: tuple(string time_lapsed, string spoken_time) like format.
1249        OUTPUT Variables: time_lapsed, spoken_time
1250
1251        Example  Input: duration_from_nanoseconds(ns)
1252        **'Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds'**
1253        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')
1254        duration_from_nanoseconds(1234567890123456789012)
1255        """
1256        us, ns = divmod(ns, 1000)
1257        ms, us = divmod(us, 1000)
1258        s, ms = divmod(ms, 1000)
1259        m, s = divmod(s, 60)
1260        h, m = divmod(m, 60)
1261        d, h = divmod(h, 24)
1262        y, d = divmod(d, 365)
1263        c, y = divmod(y, 100)
1264        n, c = divmod(c, 10)
1265        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}'
1266        spoken_time_part = []
1267        if n > 0 or show_zeros_in_spoken_time:
1268            spoken_time_part.append(f'{n: 3d} {millennia}')
1269        if c > 0 or show_zeros_in_spoken_time:
1270            spoken_time_part.append(f'{c: 4d} {century}')
1271        if y > 0 or show_zeros_in_spoken_time:
1272            spoken_time_part.append(f'{y: 3d} {years}')
1273        if d > 0 or show_zeros_in_spoken_time:
1274            spoken_time_part.append(f'{d: 4d} {days}')
1275        if h > 0 or show_zeros_in_spoken_time:
1276            spoken_time_part.append(f'{h: 2d} {hours}')
1277        if m > 0 or show_zeros_in_spoken_time:
1278            spoken_time_part.append(f'{m: 2d} {minutes}')
1279        if s > 0 or show_zeros_in_spoken_time:
1280            spoken_time_part.append(f'{s: 2d} {seconds}')
1281        if ms > 0 or show_zeros_in_spoken_time:
1282            spoken_time_part.append(f'{ms: 3d} {milli_seconds}')
1283        if us > 0 or show_zeros_in_spoken_time:
1284            spoken_time_part.append(f'{us: 3d} {micro_seconds}')
1285        if ns > 0 or show_zeros_in_spoken_time:
1286            spoken_time_part.append(f'{ns: 3d} {nano_seconds}')
1287        return time_lapsed, spoken_time_separator.join(spoken_time_part)
1288
1289    @staticmethod
1290    def test(debug: bool = False):
1291        """
1292        Performs unit tests to verify the correctness of the `Time` class methods.
1293
1294        This method checks the conversion between datetime objects and timestamps,
1295        ensuring accuracy and consistency across various date ranges.
1296
1297        Parameters:
1298        - debug (bool, optional): If True, prints the timestamp and converted datetime for each test case. Defaults to False.
1299        """
1300        test_cases = [
1301            datetime.datetime(1, 1, 1),
1302            datetime.datetime(1970, 1, 1),
1303            datetime.datetime(1969, 12, 31),
1304            datetime.datetime.now(),
1305            datetime.datetime(9999, 12, 31, 23, 59, 59),
1306        ]
1307
1308        for test_date in test_cases:
1309            timestamp = Time.time(test_date)
1310            converted = Time.time_to_datetime(timestamp)
1311            if debug:
1312                print(f'{timestamp} <=> {converted}')
1313            assert timestamp > 0
1314            assert test_date.year == converted.year
1315            assert test_date.month == converted.month
1316            assert test_date.day == converted.day
1317            assert test_date.hour == converted.hour
1318            assert test_date.minute == converted.minute
1319            assert test_date.second in [converted.second - 1, converted.second, converted.second + 1]
1320
1321        # sanity check - convert date since 1AD to 9999AD
1322
1323        for year in range(1, 10_000):
1324            ns = Time.time(datetime.datetime.strptime(f'{year:04d}-12-30 18:30:45.906030', '%Y-%m-%d %H:%M:%S.%f'))
1325            date = Time.time_to_datetime(ns)
1326            if debug:
1327                print(date, date.microsecond)
1328            assert ns > 0
1329            assert date.year == year
1330            assert date.month == 12
1331            assert date.day == 30
1332            assert date.hour == 18
1333            assert date.minute == 30
1334            assert date.second in [44, 45]
1335            #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]:
1136    @staticmethod
1137    def minimum_time_diff_ns() -> tuple[int, int]:
1138        """
1139        Calculates the minimum time difference between two consecutive calls to
1140        `Time._time()` in nanoseconds.
1141
1142        This method is used internally to determine the minimum granularity of
1143        time measurements within the system.
1144
1145        Returns:
1146        - tuple[int, int]:
1147            - The minimum time difference in nanoseconds.
1148            - The number of iterations required to measure the difference.
1149        """
1150        i = 0
1151        x = y = Time._time()
1152        while x == y:
1153            y = Time._time()
1154            i += 1
1155        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:
1179    @staticmethod
1180    def time(now: Optional[datetime.datetime] = None) -> Timestamp:
1181        """
1182        Generates a unique, monotonically increasing timestamp based on the provided
1183        datetime object or the current datetime.
1184
1185        This method ensures that timestamps are unique even if called in rapid succession
1186        by introducing a small delay if necessary, based on the system's minimum
1187        time resolution.
1188
1189        Parameters:
1190        - now (datetime.datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used.
1191
1192        Returns:
1193        - Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD).
1194        """
1195        new_time = Time._time(now)
1196        if Time.__last_time_ns is None:
1197            Time.__last_time_ns = new_time
1198            return new_time
1199        while new_time == Time.__last_time_ns:
1200            if Time.__time_diff_ns is None:
1201                diff, _ = Time.minimum_time_diff_ns()
1202                Time.__time_diff_ns = math.ceil(diff)
1203            time.sleep(Time.__time_diff_ns / 1_000_000_000)
1204            new_time = Time._time()
1205        Time.__last_time_ns = new_time
1206        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:
1208    @staticmethod
1209    def time_to_datetime(ordinal_ns: Timestamp) -> datetime.datetime:
1210        """
1211        Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD)
1212        back to a datetime object.
1213
1214        Parameters:
1215        - ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD).
1216
1217        Returns:
1218        - datetime.datetime: The corresponding datetime object.
1219        """
1220        d = datetime.datetime.fromordinal(ordinal_ns // 86_400_000_000_000)
1221        t = datetime.timedelta(seconds=(ordinal_ns % 86_400_000_000_000) // 10 ** 9)
1222        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:
1224    @staticmethod
1225    def duration_from_nanoseconds(ns: int,
1226                                  show_zeros_in_spoken_time: bool = False,
1227                                  spoken_time_separator=',',
1228                                  millennia: str = 'Millennia',
1229                                  century: str = 'Century',
1230                                  years: str = 'Years',
1231                                  days: str = 'Days',
1232                                  hours: str = 'Hours',
1233                                  minutes: str = 'Minutes',
1234                                  seconds: str = 'Seconds',
1235                                  milli_seconds: str = 'MilliSeconds',
1236                                  micro_seconds: str = 'MicroSeconds',
1237                                  nano_seconds: str = 'NanoSeconds',
1238                                  ) -> tuple:
1239        """
1240        REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
1241        Convert NanoSeconds to Human Readable Time Format.
1242        A NanoSeconds is a unit of time in the International System of Units (SI) equal
1243        to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
1244        Its symbol is μs, sometimes simplified to us when Unicode is not available.
1245        A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
1246
1247        INPUT : ms (AKA: MilliSeconds)
1248        OUTPUT: tuple(string time_lapsed, string spoken_time) like format.
1249        OUTPUT Variables: time_lapsed, spoken_time
1250
1251        Example  Input: duration_from_nanoseconds(ns)
1252        **'Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds'**
1253        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')
1254        duration_from_nanoseconds(1234567890123456789012)
1255        """
1256        us, ns = divmod(ns, 1000)
1257        ms, us = divmod(us, 1000)
1258        s, ms = divmod(ms, 1000)
1259        m, s = divmod(s, 60)
1260        h, m = divmod(m, 60)
1261        d, h = divmod(h, 24)
1262        y, d = divmod(d, 365)
1263        c, y = divmod(y, 100)
1264        n, c = divmod(c, 10)
1265        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}'
1266        spoken_time_part = []
1267        if n > 0 or show_zeros_in_spoken_time:
1268            spoken_time_part.append(f'{n: 3d} {millennia}')
1269        if c > 0 or show_zeros_in_spoken_time:
1270            spoken_time_part.append(f'{c: 4d} {century}')
1271        if y > 0 or show_zeros_in_spoken_time:
1272            spoken_time_part.append(f'{y: 3d} {years}')
1273        if d > 0 or show_zeros_in_spoken_time:
1274            spoken_time_part.append(f'{d: 4d} {days}')
1275        if h > 0 or show_zeros_in_spoken_time:
1276            spoken_time_part.append(f'{h: 2d} {hours}')
1277        if m > 0 or show_zeros_in_spoken_time:
1278            spoken_time_part.append(f'{m: 2d} {minutes}')
1279        if s > 0 or show_zeros_in_spoken_time:
1280            spoken_time_part.append(f'{s: 2d} {seconds}')
1281        if ms > 0 or show_zeros_in_spoken_time:
1282            spoken_time_part.append(f'{ms: 3d} {milli_seconds}')
1283        if us > 0 or show_zeros_in_spoken_time:
1284            spoken_time_part.append(f'{us: 3d} {micro_seconds}')
1285        if ns > 0 or show_zeros_in_spoken_time:
1286            spoken_time_part.append(f'{ns: 3d} {nano_seconds}')
1287        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):
1289    @staticmethod
1290    def test(debug: bool = False):
1291        """
1292        Performs unit tests to verify the correctness of the `Time` class methods.
1293
1294        This method checks the conversion between datetime objects and timestamps,
1295        ensuring accuracy and consistency across various date ranges.
1296
1297        Parameters:
1298        - debug (bool, optional): If True, prints the timestamp and converted datetime for each test case. Defaults to False.
1299        """
1300        test_cases = [
1301            datetime.datetime(1, 1, 1),
1302            datetime.datetime(1970, 1, 1),
1303            datetime.datetime(1969, 12, 31),
1304            datetime.datetime.now(),
1305            datetime.datetime(9999, 12, 31, 23, 59, 59),
1306        ]
1307
1308        for test_date in test_cases:
1309            timestamp = Time.time(test_date)
1310            converted = Time.time_to_datetime(timestamp)
1311            if debug:
1312                print(f'{timestamp} <=> {converted}')
1313            assert timestamp > 0
1314            assert test_date.year == converted.year
1315            assert test_date.month == converted.month
1316            assert test_date.day == converted.day
1317            assert test_date.hour == converted.hour
1318            assert test_date.minute == converted.minute
1319            assert test_date.second in [converted.second - 1, converted.second, converted.second + 1]
1320
1321        # sanity check - convert date since 1AD to 9999AD
1322
1323        for year in range(1, 10_000):
1324            ns = Time.time(datetime.datetime.strptime(f'{year:04d}-12-30 18:30:45.906030', '%Y-%m-%d %H:%M:%S.%f'))
1325            date = Time.time_to_datetime(ns)
1326            if debug:
1327                print(date, date.microsecond)
1328            assert ns > 0
1329            assert date.year == year
1330            assert date.month == 12
1331            assert date.day == 30
1332            assert date.hour == 18
1333            assert date.minute == 30
1334            assert date.second in [44, 45]
1335            #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:
1179    @staticmethod
1180    def time(now: Optional[datetime.datetime] = None) -> Timestamp:
1181        """
1182        Generates a unique, monotonically increasing timestamp based on the provided
1183        datetime object or the current datetime.
1184
1185        This method ensures that timestamps are unique even if called in rapid succession
1186        by introducing a small delay if necessary, based on the system's minimum
1187        time resolution.
1188
1189        Parameters:
1190        - now (datetime.datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used.
1191
1192        Returns:
1193        - Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD).
1194        """
1195        new_time = Time._time(now)
1196        if Time.__last_time_ns is None:
1197            Time.__last_time_ns = new_time
1198            return new_time
1199        while new_time == Time.__last_time_ns:
1200            if Time.__time_diff_ns is None:
1201                diff, _ = Time.minimum_time_diff_ns()
1202                Time.__time_diff_ns = math.ceil(diff)
1203            time.sleep(Time.__time_diff_ns / 1_000_000_000)
1204            new_time = Time._time()
1205        Time.__last_time_ns = new_time
1206        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:
1208    @staticmethod
1209    def time_to_datetime(ordinal_ns: Timestamp) -> datetime.datetime:
1210        """
1211        Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD)
1212        back to a datetime object.
1213
1214        Parameters:
1215        - ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD).
1216
1217        Returns:
1218        - datetime.datetime: The corresponding datetime object.
1219        """
1220        d = datetime.datetime.fromordinal(ordinal_ns // 86_400_000_000_000)
1221        t = datetime.timedelta(seconds=(ordinal_ns % 86_400_000_000_000) // 10 ** 9)
1222        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):
832@dataclasses.dataclass
833class SizeInfo(StrictDataclass):
834    """
835    Represents size information in bytes and human-readable format.
836    
837    Attributes:
838    - bytes (float): The size in bytes.
839    - human_readable (str): The human-readable representation of the size.
840    """
841    bytes: float
842    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):
845@dataclasses.dataclass
846class FileInfo(StrictDataclass):
847    """
848    Represents information about a file.
849    
850    Attributes:
851    - type (str): The type of the file.
852    - path (str): The full path to the file.
853    - exists (bool): A boolean indicating whether the file exists.
854    - size (int): The size of the file in bytes.
855    - human_readable_size (str): The human-readable representation of the file size.
856    """
857    type: str
858    path: str
859    exists: bool
860    size: int
861    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):
864@dataclasses.dataclass
865class FileStats(StrictDataclass):
866    """
867    Represents statistics related to file storage.
868    
869    Attributes:
870    - ram (:class:`SizeInfo`): Information about the RAM usage.
871    - database (:class:`SizeInfo`): Information about the database size.
872    """
873    ram: SizeInfo
874    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):
877@dataclasses.dataclass
878class TimeSummary(StrictDataclass):
879    """Summary of positive, negative, and total values over a period."""
880    positive: int = 0
881    negative: int = 0
882    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):
885@dataclasses.dataclass
886class Transaction(StrictDataclass):
887    """Represents a single transaction record."""
888    account: str
889    account_id: AccountID
890    log_ref: Timestamp
891    desc: str
892    file: dict[Timestamp, str]
893    value: int
894    time: Timestamp
895    transfer: bool

Represents a single transaction record.

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

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:
1938    def free(self, lock: Timestamp, auto_save: bool = True) -> bool:
1939        """
1940        Releases the lock on the database.
1941
1942        Parameters:
1943        - lock (Timestamp): The lock ID to be released.
1944        - auto_save (bool, optional): Whether to automatically save the database after releasing the lock.
1945
1946        Returns:
1947        - bool: True if the lock is successfully released and (optionally) saved, False otherwise.
1948        """
1949        if lock == self.__vault.lock:
1950            self.clean_history(lock)
1951            self.__vault.lock = None
1952            if auto_save and not self.memory_mode():
1953                return self.save(self.path())
1954            return True
1955        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:
1957    def recall(self, dry: bool = True, lock: Optional[Timestamp] = None, debug: bool = False) -> bool:
1958        """
1959        Revert the last operation.
1960
1961        Parameters:
1962        - dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
1963        - lock (Timestamp, optional): An optional lock value to ensure the recall
1964                operation is performed on the expected history entry. If provided,
1965                it checks if the current lock and the most recent history key
1966                match the given lock value. Defaults to None.
1967        - debug (bool, optional): If True, the function will print debug information. Default is False.
1968
1969        Returns:
1970        - bool: True if the operation was successful, False otherwise.
1971        """
1972        if not self.nolock() or len(self.__vault.history) == 0:
1973            return False
1974        if len(self.__vault.history) <= 0:
1975            return False
1976        ref = sorted(self.__vault.history.keys())[-1]
1977        if debug:
1978            print('recall', ref)
1979        memory = sorted(self.__vault.history[ref], reverse=True)
1980        if debug:
1981            print(type(memory), 'memory', memory)
1982        if lock is not None:
1983            assert self.__vault.lock == lock, "Invalid current lock"
1984            assert ref == lock, "Invalid last lock"
1985            assert self.__history(), "History mode should be enabled, found off!!!"
1986        sub_positive_log_negative = 0
1987        for i in memory:
1988            x = self.__vault.history[ref][i]
1989            if debug:
1990                print(type(x), x)
1991            if x.action != Action.REPORT:
1992                assert x.account is not None
1993                if x.action != Action.EXCHANGE:
1994                    assert self.account_exists(x.account)
1995            match x.action:
1996                case Action.CREATE:
1997                    if debug:
1998                        print('account', self.__vault.account[x.account])
1999                    assert len(self.__vault.account[x.account].box) == 0
2000                    assert len(self.__vault.account[x.account].log) == 0
2001                    assert self.__vault.account[x.account].balance == 0
2002                    assert self.__vault.account[x.account].count == 0
2003                    assert self.__vault.account[x.account].name == ''
2004                    if dry:
2005                        continue
2006                    del self.__vault.account[x.account]
2007
2008                case Action.NAME:
2009                    assert x.value is not None
2010                    if dry:
2011                        continue
2012                    self.__vault.account[x.account].name = x.value
2013
2014                case Action.TRACK:
2015                    assert x.value is not None
2016                    assert x.ref is not None
2017                    if dry:
2018                        continue
2019                    self.__vault.account[x.account].balance -= x.value
2020                    self.__vault.account[x.account].count -= 1
2021                    del self.__vault.account[x.account].box[x.ref]
2022
2023                case Action.LOG:
2024                    assert x.ref in self.__vault.account[x.account].log
2025                    assert x.value is not None
2026                    if dry:
2027                        continue
2028                    if sub_positive_log_negative == -x.value:
2029                        self.__vault.account[x.account].count -= 1
2030                        sub_positive_log_negative = 0
2031                    box_ref = self.__vault.account[x.account].log[x.ref].ref
2032                    if not box_ref is None:
2033                        assert self.box_exists(x.account, box_ref)
2034                        box_value = self.__vault.account[x.account].log[x.ref].value
2035                        assert box_value < 0
2036
2037                        try:
2038                            self.__vault.account[x.account].box[box_ref].rest += -box_value
2039                        except TypeError:
2040                            self.__vault.account[x.account].box[box_ref].rest += decimal.Decimal(-box_value)
2041
2042                        try:
2043                            self.__vault.account[x.account].balance += -box_value
2044                        except TypeError:
2045                            self.__vault.account[x.account].balance += decimal.Decimal(-box_value)
2046
2047                        self.__vault.account[x.account].count -= 1
2048                    del self.__vault.account[x.account].log[x.ref]
2049
2050                case Action.SUBTRACT:
2051                    assert x.ref in self.__vault.account[x.account].box
2052                    assert x.value is not None
2053                    if dry:
2054                        continue
2055                    self.__vault.account[x.account].box[x.ref].rest += x.value
2056                    self.__vault.account[x.account].balance += x.value
2057                    sub_positive_log_negative = x.value
2058
2059                case Action.ADD_FILE:
2060                    assert x.ref in self.__vault.account[x.account].log
2061                    assert x.file is not None
2062                    assert dry or x.file in self.__vault.account[x.account].log[x.ref].file
2063                    if dry:
2064                        continue
2065                    del self.__vault.account[x.account].log[x.ref].file[x.file]
2066
2067                case Action.REMOVE_FILE:
2068                    assert x.ref in self.__vault.account[x.account].log
2069                    assert x.file is not None
2070                    assert x.value is not None
2071                    if dry:
2072                        continue
2073                    self.__vault.account[x.account].log[x.ref].file[x.file] = x.value
2074
2075                case Action.BOX_TRANSFER:
2076                    assert x.ref in self.__vault.account[x.account].box
2077                    assert x.value is not None
2078                    if dry:
2079                        continue
2080                    self.__vault.account[x.account].box[x.ref].rest -= x.value
2081
2082                case Action.EXCHANGE:
2083                    assert x.account in self.__vault.exchange
2084                    assert x.ref in self.__vault.exchange[x.account]
2085                    if dry:
2086                        continue
2087                    del self.__vault.exchange[x.account][x.ref]
2088
2089                case Action.REPORT:
2090                    assert x.ref in self.__vault.report
2091                    if dry:
2092                        continue
2093                    del self.__vault.report[x.ref]
2094
2095                case Action.ZAKAT:
2096                    assert x.ref in self.__vault.account[x.account].box
2097                    assert x.key is not None
2098                    assert hasattr(self.__vault.account[x.account].box[x.ref].zakat, x.key)
2099                    if dry:
2100                        continue
2101                    match x.math:
2102                        case MathOperation.ADDITION:
2103                            setattr(
2104                                self.__vault.account[x.account].box[x.ref].zakat,
2105                                x.key,
2106                                getattr(self.__vault.account[x.account].box[x.ref].zakat, x.key) - x.value,
2107                            )
2108                        case MathOperation.EQUAL:
2109                            setattr(
2110                                self.__vault.account[x.account].box[x.ref].zakat,
2111                                x.key,
2112                                x.value,
2113                            )
2114                        case MathOperation.SUBTRACTION:
2115                            setattr(
2116                                self.__vault.account[x.account].box[x.ref],
2117                                x.key,
2118                                getattr(self.__vault.account[x.account].box[x.ref], x.key) + x.value,
2119                            )
2120
2121        if not dry:
2122            del self.__vault.history[ref]
2123        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:
2125    def vault(self) -> dict:
2126        """
2127        Returns a copy of the internal vault dictionary.
2128
2129        This method is used to retrieve the current state of the ZakatTracker object.
2130        It provides a snapshot of the internal data structure, allowing for further
2131        processing or analysis.
2132
2133        Parameters:
2134        None
2135
2136        Returns:
2137        - dict: A copy of the internal vault dictionary.
2138        """
2139        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:
2141    @staticmethod
2142    def stats_init() -> FileStats:
2143        """
2144        Initialize and return the initial file statistics.
2145
2146        Returns:
2147        - FileStats: A :class:`FileStats` instance with initial values
2148            of 0 bytes for both RAM and database.
2149        """
2150        return FileStats(
2151            database=SizeInfo(0, '0'),
2152            ram=SizeInfo(0, '0'),
2153        )

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:
2155    def stats(self, ignore_ram: bool = True) -> FileStats:
2156        """
2157        Calculates and returns statistics about the object's data storage.
2158
2159        This method determines the size of the database file on disk and the
2160        size of the data currently held in RAM (likely within a dictionary).
2161        Both sizes are reported in bytes and in a human-readable format
2162        (e.g., KB, MB).
2163
2164        Parameters:
2165        - ignore_ram (bool, optional): Whether to ignore the RAM size. Default is True
2166
2167        Returns:
2168        - FileStats: A dataclass containing the following statistics:
2169
2170            * 'database': A tuple with two elements:
2171                - The database file size in bytes (float).
2172                - The database file size in human-readable format (str).
2173            * 'ram': A tuple with two elements:
2174                - The RAM usage (dictionary size) in bytes (float).
2175                - The RAM usage in human-readable format (str).
2176
2177        Example:
2178        ```bash
2179        >>> x = ZakatTracker()
2180        >>> stats = x.stats()
2181        >>> print(stats.database)
2182        SizeInfo(bytes=256000, human_readable='250.0 KB')
2183        >>> print(stats.ram)
2184        SizeInfo(bytes=12345, human_readable='12.1 KB')
2185        ```
2186        """
2187        ram_size = 0.0 if ignore_ram else self.get_dict_size(self.vault())
2188        file_size = os.path.getsize(self.path())
2189        return FileStats(
2190            database=SizeInfo(file_size, self.human_readable_size(file_size)),
2191            ram=SizeInfo(ram_size, self.human_readable_size(ram_size)),
2192        )

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]:
2194    def files(self) -> list[FileInfo]:
2195        """
2196        Retrieves information about files associated with this class.
2197
2198        This class method provides a standardized way to gather details about
2199        files used by the class for storage, snapshots, and CSV imports.
2200
2201        Parameters:
2202        None
2203
2204        Returns:
2205        - list[FileInfo]: A list of dataclass, each containing information
2206            about a specific file:
2207
2208            * type (str): The type of file ('database', 'snapshot', 'import_csv').
2209            * path (str): The full file path.
2210            * exists (bool): Whether the file exists on the filesystem.
2211            * size (int): The file size in bytes (0 if the file doesn't exist).
2212            * human_readable_size (str): A human-friendly representation of the file size (e.g., '10 KB', '2.5 MB').
2213        """
2214        result = []
2215        for file_type, path in {
2216            'database': self.path(),
2217            'snapshot': self.snapshot_cache_path(),
2218            'import_csv': self.import_csv_cache_path(),
2219        }.items():
2220            exists = os.path.exists(path)
2221            size = os.path.getsize(path) if exists else 0
2222            human_readable_size = self.human_readable_size(size) if exists else '0'
2223            result.append(FileInfo(
2224                type=file_type,
2225                path=path,
2226                exists=exists,
2227                size=size,
2228                human_readable_size=human_readable_size,
2229            ))
2230        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:
2232    def account_exists(self, account: AccountID) -> bool:
2233        """
2234        Check if the given account exists in the vault.
2235
2236        Parameters:
2237        - account (AccountID): The account reference to check.
2238
2239        Returns:
2240        - bool: True if the account exists, False otherwise.
2241        """
2242        account = AccountID(account)
2243        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:
2245    def box_size(self, account: AccountID) -> int:
2246        """
2247        Calculate the size of the box for a specific account.
2248
2249        Parameters:
2250        - account (AccountID): The account reference for which the box size needs to be calculated.
2251
2252        Returns:
2253        - int: The size of the box for the given account. If the account does not exist, -1 is returned.
2254        """
2255        if self.account_exists(account):
2256            return len(self.__vault.account[account].box)
2257        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:
2259    def log_size(self, account: AccountID) -> int:
2260        """
2261        Get the size of the log for a specific account.
2262
2263        Parameters:
2264        - account (AccountID): The account reference for which the log size needs to be calculated.
2265
2266        Returns:
2267        - int: The size of the log for the given account. If the account does not exist, -1 is returned.
2268        """
2269        if self.account_exists(account):
2270            return len(self.__vault.account[account].log)
2271        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:
2273    @staticmethod
2274    def hash_data(data: bytes, algorithm: str = 'blake2b') -> str:
2275        """
2276        Calculates the hash of given byte data using the specified algorithm.
2277
2278        Parameters:
2279        - data (bytes): The byte data to hash.
2280        - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'.
2281
2282        Returns:
2283        - str: The hexadecimal representation of the data's hash.
2284        """
2285        hash_obj = hashlib.new(algorithm)
2286        hash_obj.update(data)
2287        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:
2289    @staticmethod
2290    def hash_file(file_path: str, algorithm: str = 'blake2b') -> str:
2291        """
2292        Calculates the hash of a file using the specified algorithm.
2293
2294        Parameters:
2295        - file_path (str): The path to the file.
2296        - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'.
2297
2298        Returns:
2299        - str: The hexadecimal representation of the file's hash.
2300        """
2301        hash_obj = hashlib.new(algorithm)  # Create the hash object
2302        with open(file_path, 'rb') as file:  # Open file in binary mode for reading
2303            for chunk in iter(lambda: file.read(4096), b''):  # Read file in chunks
2304                hash_obj.update(chunk)
2305        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):
2307    def snapshot_cache_path(self):
2308        """
2309        Generate the path for the cache file used to store snapshots.
2310
2311        The cache file is a json file that stores the timestamps of the snapshots.
2312        The file name is derived from the main database file name by replacing the '.json' extension with '.snapshots.json'.
2313
2314        Parameters:
2315        None
2316
2317        Returns:
2318        - str: The path to the cache file.
2319        """
2320        path = str(self.path())
2321        ext = self.ext()
2322        ext_len = len(ext)
2323        if path.endswith(f'.{ext}'):
2324            path = path[:-ext_len - 1]
2325        _, filename = os.path.split(path + f'.snapshots.{ext}')
2326        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:
2328    def snapshot(self) -> bool:
2329        """
2330        This function creates a snapshot of the current database state.
2331
2332        The function calculates the hash of the current database file and checks if a snapshot with the same hash already exists.
2333        If a snapshot with the same hash exists, the function returns True without creating a new snapshot.
2334        If a snapshot with the same hash does not exist, the function creates a new snapshot by saving the current database state
2335        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.
2336
2337        Parameters:
2338        None
2339
2340        Returns:
2341        - bool: True if a snapshot with the same hash already exists or if the snapshot is successfully created. False if the snapshot creation fails.
2342        """
2343        current_hash = self.hash_file(self.path())
2344        cache: dict[str, int] = {}  # hash: time_ns
2345        try:
2346            with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream:
2347                cache = json.load(stream, cls=JSONDecoder)
2348        except:
2349            pass
2350        if current_hash in cache:
2351            return True
2352        ref = time.time_ns()
2353        cache[current_hash] = ref
2354        if not self.save(self.base_path('snapshots', f'{ref}.{self.ext()}')):
2355            return False
2356        with open(self.snapshot_cache_path(), 'w', encoding='utf-8') as stream:
2357            stream.write(json.dumps(cache, cls=JSONEncoder))
2358        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]]:
2360    def snapshots(self, hide_missing: bool = True, verified_hash_only: bool = False) \
2361            -> dict[int, tuple[str, str, bool]]:
2362        """
2363        Retrieve a dictionary of snapshots, with their respective hashes, paths, and existence status.
2364
2365        Parameters:
2366        - hide_missing (bool, optional): If True, only include snapshots that exist in the dictionary. Default is True.
2367        - verified_hash_only (bool, optional): If True, only include snapshots with a valid hash. Default is False.
2368
2369        Returns:
2370        - dict[int, tuple[str, str, bool]]: A dictionary where the keys are the timestamps of the snapshots,
2371        and the values are tuples containing the snapshot's hash, path, and existence status.
2372        """
2373        cache: dict[str, int] = {}  # hash: time_ns
2374        try:
2375            with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream:
2376                cache = json.load(stream, cls=JSONDecoder)
2377        except:
2378            pass
2379        if not cache:
2380            return {}
2381        result: dict[int, tuple[str, str, bool]] = {}  # time_ns: (hash, path, exists)
2382        for hash_file, ref in cache.items():
2383            path = self.base_path('snapshots', f'{ref}.{self.ext()}')
2384            exists = os.path.exists(path)
2385            valid_hash = self.hash_file(path) == hash_file if verified_hash_only else True
2386            if (verified_hash_only and not valid_hash) or (verified_hash_only and not exists):
2387                continue
2388            if exists or not hide_missing:
2389                result[ref] = (hash_file, path, exists)
2390        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:
2392    def ref_exists(self, account: AccountID, ref_type: str, ref: Timestamp) -> bool:
2393        """
2394        Check if a specific reference (transaction) exists in the vault for a given account and reference type.
2395
2396        Parameters:
2397        - account (AccountID): The account reference for which to check the existence of the reference.
2398        - ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
2399        - ref (Timestamp): The reference (transaction) number to check for existence.
2400
2401        Returns:
2402        - bool: True if the reference exists for the given account and reference type, False otherwise.
2403        """
2404        account = AccountID(account)
2405        if account in self.__vault.account:
2406            return ref in getattr(self.__vault.account[account], ref_type)
2407        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:
2409    def box_exists(self, account: AccountID, ref: Timestamp) -> bool:
2410        """
2411        Check if a specific box (transaction) exists in the vault for a given account and reference.
2412
2413        Parameters:
2414        - account (AccountID): The account reference for which to check the existence of the box.
2415        - ref (Timestamp): The reference (transaction) number to check for existence.
2416
2417        Returns:
2418        - bool: True if the box exists for the given account and reference, False otherwise.
2419        """
2420        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]:
2422    def track(self, unscaled_value: float | int | decimal.Decimal = 0, desc: str = '', account: AccountID = AccountID('1'),
2423              created_time_ns: Optional[Timestamp] = None,
2424              debug: bool = False) -> Optional[Timestamp]:
2425        """
2426        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.
2427
2428        Parameters:
2429        - unscaled_value (float | int | decimal.Decimal, optional): The value of the transaction. Default is 0.
2430        - desc (str, optional): The description of the transaction. Default is an empty string.
2431        - account (AccountID, optional): The account reference for which the transaction is being tracked. Default is '1'.
2432        - 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.
2433        - debug (bool, optional): Whether to print debug information. Default is False.
2434
2435        Returns:
2436        - Optional[Timestamp]: The timestamp of the transaction in nanoseconds since epoch(1AD).
2437
2438        Raises:
2439        - ValueError: The created_time_ns should be greater than zero.
2440        - ValueError: The log transaction happened again in the same nanosecond time.
2441        - ValueError: The box transaction happened again in the same nanosecond time.
2442        """
2443        return self.__track(
2444            unscaled_value=unscaled_value,
2445            desc=desc,
2446            account=account,
2447            logging=True,
2448            created_time_ns=created_time_ns,
2449            debug=debug,
2450        )

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:
2518    def log_exists(self, account: AccountID, ref: Timestamp) -> bool:
2519        """
2520        Checks if a specific transaction log entry exists for a given account.
2521
2522        Parameters:
2523        - account (AccountID): The account reference associated with the transaction log.
2524        - ref (Timestamp): The reference to the transaction log entry.
2525
2526        Returns:
2527        - bool: True if the transaction log entry exists, False otherwise.
2528        """
2529        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:
2582    def exchange(self, account: AccountID, created_time_ns: Optional[Timestamp] = None,
2583                 rate: Optional[float] = None, description: Optional[str] = None, debug: bool = False) -> Exchange:
2584        """
2585        This method is used to record or retrieve exchange rates for a specific account.
2586
2587        Parameters:
2588        - account (AccountID): The account reference for which the exchange rate is being recorded or retrieved.
2589        - created_time_ns (Timestamp, optional): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
2590        - rate (float, optional): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
2591        - description (str, optional): A description of the exchange rate.
2592        - debug (bool, optional): Whether to print debug information. Default is False.
2593
2594        Returns:
2595        - Exchange: A dictionary containing the latest exchange rate and its description. If no exchange rate is found,
2596        it returns a dictionary with default values for the rate and description.
2597
2598        Raises:
2599        - ValueError: The created should be greater than zero.
2600        """
2601        if debug:
2602            print('exchange', f'debug={debug}')
2603        account = AccountID(account)
2604        if created_time_ns is None:
2605            created_time_ns = Time.time()
2606        if created_time_ns <= 0:
2607            raise ValueError('The created should be greater than zero.')
2608        if rate is not None:
2609            if rate <= 0:
2610                return Exchange()
2611            if account not in self.__vault.exchange:
2612                self.__vault.exchange[account] = {}
2613            if len(self.__vault.exchange[account]) == 0 and rate <= 1:
2614                return Exchange(time=created_time_ns, rate=1)
2615            no_lock = self.nolock()
2616            lock = self.__lock()
2617            self.__vault.exchange[account][created_time_ns] = Exchange(rate=rate, description=description)
2618            self.__step(Action.EXCHANGE, account, ref=created_time_ns, value=rate)
2619            if no_lock:
2620                assert lock is not None
2621                self.free(lock)
2622            if debug:
2623                print('exchange-created-1',
2624                      f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}')
2625
2626        if account in self.__vault.exchange:
2627            valid_rates = [(ts, r) for ts, r in self.__vault.exchange[account].items() if ts <= created_time_ns]
2628            if valid_rates:
2629                latest_rate = max(valid_rates, key=lambda x: x[0])
2630                if debug:
2631                    print('exchange-read-1',
2632                          f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}',
2633                          'latest_rate', latest_rate)
2634                result = latest_rate[1]
2635                result.time = latest_rate[0]
2636                return result  # إرجاع قاموس يحتوي على المعدل والوصف
2637        if debug:
2638            print('exchange-read-0', f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}')
2639        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:
2641    @staticmethod
2642    def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
2643        """
2644        This function calculates the exchanged amount of a currency.
2645
2646        Parameters:
2647        - x (float): The original amount of the currency.
2648        - x_rate (float): The exchange rate of the original currency.
2649        - y_rate (float): The exchange rate of the target currency.
2650
2651        Returns:
2652        - float: The exchanged amount of the target currency.
2653        """
2654        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]]:
2656    def exchanges(self) -> dict[AccountID, dict[Timestamp, Exchange]]:
2657        """
2658        Retrieve the recorded exchange rates for all accounts.
2659
2660        Parameters:
2661        None
2662
2663        Returns:
2664        - dict[AccountID, dict[Timestamp, Exchange]]: A dictionary containing all recorded exchange rates.
2665        The keys are account references or numbers, and the values are dictionaries containing the exchange rates.
2666        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
2667        """
2668        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]:
2670    def accounts(self) -> dict[AccountID, AccountDetails]:
2671        """
2672        Returns a dictionary containing account references as keys and their respective account details as values.
2673
2674        Parameters:
2675        None
2676
2677        Returns:
2678        - dict[AccountID, AccountDetails]: A dictionary where keys are account references and values are their respective details.
2679        """
2680        return {
2681            account_id: AccountDetails(
2682                account_id=account_id,
2683                account_name=self.__vault.account[account_id].name,
2684                balance=self.__vault.account[account_id].balance,
2685            )
2686            for account_id in self.__vault.account
2687        }

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]:
2689    def boxes(self, account: AccountID) -> dict[Timestamp, Box]:
2690        """
2691        Retrieve the boxes (transactions) associated with a specific account.
2692
2693        Parameters:
2694        - account (AccountID): The account reference for which to retrieve the boxes.
2695
2696        Returns:
2697        - dict[Timestamp, Box]: A dictionary containing the boxes associated with the given account.
2698        If the account does not exist, an empty dictionary is returned.
2699        """
2700        if self.account_exists(account):
2701            return self.__vault.account[account].box
2702        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]:
2704    def logs(self, account: AccountID) -> dict[Timestamp, Log]:
2705        """
2706        Retrieve the logs (transactions) associated with a specific account.
2707
2708        Parameters:
2709        - account (AccountID): The account reference for which to retrieve the logs.
2710
2711        Returns:
2712        - dict[Timestamp, Log]: A dictionary containing the logs associated with the given account.
2713        If the account does not exist, an empty dictionary is returned.
2714        """
2715        if self.account_exists(account):
2716            return self.__vault.account[account].log
2717        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:
2719    def timeline(self, weekday: WeekDay = WeekDay.FRIDAY, debug: bool = False) -> Timeline:
2720        """
2721        Aggregates transaction logs into a structured timeline.
2722
2723        This method retrieves transaction logs from all accounts and organizes them
2724        into daily, weekly, monthly, and yearly summaries. Each level of the
2725        timeline includes a `TimeSummary` object with the total positive, negative,
2726        and overall values for that period. The daily level also includes a list
2727        of individual `Transaction` records.
2728
2729        Parameters:
2730        - weekday (WeekDay, optional): The day of the week to use as the anchor
2731                for weekly summaries. Defaults to WeekDay.FRIDAY.
2732        - debug (bool, optional): If True, prints intermediate debug information
2733                during processing. Defaults to False.
2734
2735        Returns:
2736        - Timeline: An object containing the aggregated transaction data, organized
2737                into daily, weekly, monthly, and yearly summaries. The 'daily'
2738                attribute is a dictionary where keys are dates (YYYY-MM-DD) and
2739                values are `DailyRecords` objects. The 'weekly' attribute is a
2740                dictionary where keys are the starting datetime of the week and
2741                values are `TimeSummary` objects. The 'monthly' attribute is a
2742                dictionary where keys are year-month strings (YYYY-MM) and values
2743                are `TimeSummary` objects. The 'yearly' attribute is a dictionary
2744                where keys are years (YYYY) and values are `TimeSummary` objects.
2745
2746        Example:
2747        ```bash
2748        >>> from zakat import tracker
2749        >>> ledger = tracker(':memory:')
2750        >>> account1_id = ledger.create_account('account1')
2751        >>> account2_id = ledger.create_account('account2')
2752        >>> ledger.subtract(51, 'desc', account1_id)
2753        >>> ref = ledger.track(100, 'desc', account2_id)
2754        >>> ledger.add_file(account2_id, ref, 'file_0')
2755        >>> ledger.add_file(account2_id, ref, 'file_1')
2756        >>> ledger.add_file(account2_id, ref, 'file_2')
2757        >>> ledger.timeline()
2758        Timeline(
2759            daily={
2760                "2025-04-06": DailyRecords(
2761                    positive=10000,
2762                    negative=5100,
2763                    total=4900,
2764                    rows=[
2765                        Transaction(
2766                            account="account2",
2767                            account_id="63879638114290122752",
2768                            desc="desc2",
2769                            file={
2770                                63879638220705865728: "file_0",
2771                                63879638223391350784: "file_1",
2772                                63879638225766047744: "file_2",
2773                            },
2774                            value=10000,
2775                            time=63879638181936513024,
2776                            transfer=False,
2777                        ),
2778                        Transaction(
2779                            account="account1",
2780                            account_id="63879638104007106560",
2781                            desc="desc",
2782                            file={},
2783                            value=-5100,
2784                            time=63879638149199421440,
2785                            transfer=False,
2786                        ),
2787                    ],
2788                )
2789            },
2790            weekly={
2791                datetime.datetime(2025, 4, 2, 15, 56, 21): TimeSummary(
2792                    positive=10000, negative=0, total=10000
2793                ),
2794                datetime.datetime(2025, 4, 2, 15, 55, 49): TimeSummary(
2795                    positive=0, negative=5100, total=-5100
2796                ),
2797            },
2798            monthly={"2025-04": TimeSummary(positive=10000, negative=5100, total=4900)},
2799            yearly={2025: TimeSummary(positive=10000, negative=5100, total=4900)},
2800        )
2801        ```
2802        """
2803        logs: dict[Timestamp, list[Transaction]] = {}
2804        for account_id in self.accounts():
2805            for log_ref, log in self.logs(account_id).items():
2806                if log_ref not in logs:
2807                    logs[log_ref] = []
2808                logs[log_ref].append(Transaction(
2809                    account=self.name(account_id),
2810                    account_id=account_id,
2811                    log_ref=log_ref,
2812                    desc=log.desc,
2813                    file=log.file,
2814                    value=log.value,
2815                    time=log_ref,
2816                    transfer=False,
2817                ))
2818        if debug:
2819            print('logs', logs)
2820        y = Timeline()
2821        for i in sorted(logs, reverse=True):
2822            dt = Time.time_to_datetime(i)
2823            daily = f'{dt.year}-{dt.month:02d}-{dt.day:02d}'
2824            weekly = dt - datetime.timedelta(days=weekday.value)
2825            monthly = f'{dt.year}-{dt.month:02d}'
2826            yearly = dt.year
2827            # daily
2828            if daily not in y.daily:
2829                y.daily[daily] = DailyRecords()
2830            transfer = len(logs[i]) > 1
2831            if debug:
2832                print('logs[i]', logs[i])
2833            for z in logs[i]:
2834                if debug:
2835                    print('z', z)
2836                # daily
2837                value = z.value
2838                if value > 0:
2839                    y.daily[daily].positive += value
2840                else:
2841                    y.daily[daily].negative += -value
2842                y.daily[daily].total += value
2843                z.transfer = transfer
2844                y.daily[daily].rows.append(z)
2845                # weekly
2846                if weekly not in y.weekly:
2847                    y.weekly[weekly] = TimeSummary()
2848                if value > 0:
2849                    y.weekly[weekly].positive += value
2850                else:
2851                    y.weekly[weekly].negative += -value
2852                y.weekly[weekly].total += value
2853                # monthly
2854                if monthly not in y.monthly:
2855                    y.monthly[monthly] = TimeSummary()
2856                if value > 0:
2857                    y.monthly[monthly].positive += value
2858                else:
2859                    y.monthly[monthly].negative += -value
2860                y.monthly[monthly].total += value
2861                # yearly
2862                if yearly not in y.yearly:
2863                    y.yearly[yearly] = TimeSummary()
2864                if value > 0:
2865                    y.yearly[yearly].positive += value
2866                else:
2867                    y.yearly[yearly].negative += -value
2868                y.yearly[yearly].total += value
2869        if debug:
2870            print('y', y)
2871        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:
2873    def add_file(self, account: AccountID, ref: Timestamp, path: str) -> Timestamp:
2874        """
2875        Adds a file reference to a specific transaction log entry in the vault.
2876
2877        Parameters:
2878        - account (AccountID): The account reference associated with the transaction log.
2879        - ref (Timestamp): The reference to the transaction log entry.
2880        - path (str): The path of the file to be added.
2881
2882        Returns:
2883        - Timestamp: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
2884        """
2885        if self.account_exists(account):
2886            if ref in self.__vault.account[account].log:
2887                no_lock = self.nolock()
2888                lock = self.__lock()
2889                file_ref = Time.time()
2890                self.__vault.account[account].log[ref].file[file_ref] = path
2891                self.__step(Action.ADD_FILE, account, ref=ref, file=file_ref)
2892                if no_lock:
2893                    assert lock is not None
2894                    self.free(lock)
2895                return file_ref
2896        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:
2898    def remove_file(self, account: AccountID, ref: Timestamp, file_ref: Timestamp) -> bool:
2899        """
2900        Removes a file reference from a specific transaction log entry in the vault.
2901
2902        Parameters:
2903        - account (AccountID): The account reference associated with the transaction log.
2904        - ref (Timestamp): The reference to the transaction log entry.
2905        - file_ref (Timestamp): The reference of the file to be removed.
2906
2907        Returns:
2908        - bool: True if the file reference is successfully removed, False otherwise.
2909        """
2910        if self.account_exists(account):
2911            if ref in self.__vault.account[account].log:
2912                if file_ref in self.__vault.account[account].log[ref].file:
2913                    no_lock = self.nolock()
2914                    lock = self.__lock()
2915                    x = self.__vault.account[account].log[ref].file[file_ref]
2916                    del self.__vault.account[account].log[ref].file[file_ref]
2917                    self.__step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x)
2918                    if no_lock:
2919                        assert lock is not None
2920                        self.free(lock)
2921                    return True
2922        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:
2924    def balance(self, account: AccountID = AccountID('1'), cached: bool = True) -> int:
2925        """
2926        Calculate and return the balance of a specific account.
2927
2928        Parameters:
2929        - account (AccountID, optional): The account reference. Default is '1'.
2930        - cached (bool, optional): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
2931
2932        Returns:
2933        - int: The balance of the account.
2934
2935        Notes:
2936        - If cached is True, the function returns the cached balance.
2937        - If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
2938        """
2939        account = AccountID(account)
2940        if cached:
2941            return self.__vault.account[account].balance
2942        x = 0
2943        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:
2945    def hide(self, account: AccountID, status: Optional[bool] = None) -> bool:
2946        """
2947        Check or set the hide status of a specific account.
2948
2949        Parameters:
2950        - account (AccountID): The account reference.
2951        - status (bool, optional): The new hide status. If not provided, the function will return the current status.
2952
2953        Returns:
2954        - bool: The current or updated hide status of the account.
2955
2956        Raises:
2957        None
2958
2959        Example:
2960        ```bash
2961        >>> tracker = ZakatTracker()
2962        >>> ref = tracker.track(51, 'desc', 'account1')
2963        >>> tracker.hide('account1')  # Set the hide status of 'account1' to True
2964        False
2965        >>> tracker.hide('account1', True)  # Set the hide status of 'account1' to True
2966        True
2967        >>> tracker.hide('account1')  # Get the hide status of 'account1' by default
2968        True
2969        >>> tracker.hide('account1', False)
2970        False
2971        ```
2972        """
2973        if self.account_exists(account):
2974            if status is None:
2975                return self.__vault.account[account].hide
2976            self.__vault.account[account].hide = status
2977            return status
2978        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]:
2980    def account(self, name: str, exact: bool = True) -> Optional[AccountDetails]:
2981        """
2982        Retrieves an AccountDetails object for the first account matching the given name.
2983
2984        This method searches for accounts with names that contain the provided 'name'
2985        (case-insensitive substring matching). If a match is found, it returns an
2986        AccountDetails object containing the account's ID, name and balance. If no matching
2987        account is found, it returns None.
2988
2989        Parameters:
2990        - name: The name (or partial name) of the account to retrieve.
2991        - exact: If True, performs a case-insensitive exact match.
2992                 If False, performs a case-insensitive substring search.
2993                 Defaults to True.
2994
2995        Returns:
2996        - AccountDetails: An AccountDetails object representing the found account, or None if no
2997            matching account exists.
2998        """
2999        for account_name, account_id in self.names(name).items():
3000            if not exact or account_name.lower() == name.lower():
3001                return AccountDetails(
3002                    account_id=account_id,
3003                    account_name=account_name,
3004                    balance=self.__vault.account[account_id].balance,
3005                )
3006        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:
3008    def create_account(self, name: str) -> AccountID:
3009        """
3010        Creates a new account with the given name and returns its unique ID.
3011
3012        This method:
3013        1. Checks if an account with the same name (case-insensitive) already exists.
3014        2. Generates a unique `AccountID` based on the current time.
3015        3. Tracks the account creation internally.
3016        4. Sets the account's name.
3017        5. Verifies that the name was set correctly.
3018    
3019        Parameters:
3020        - name: The name of the new account.
3021    
3022        Returns:
3023        - AccountID: The unique `AccountID` of the newly created account.
3024    
3025        Raises:
3026        - AssertionError: Empty account name is forbidden.
3027        - AssertionError: Account name in number is forbidden.
3028        - AssertionError: If an account with the same name already exists (case-insensitive).
3029        - AssertionError: If the provided name does not match the name set for the account.
3030        """
3031        assert name.strip(), 'empty account name is forbidden'
3032        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'
3033        account_ref = self.account(name, exact=True)
3034        # check if account not exists
3035        assert account_ref is None, f'account name({name}) already used'
3036        # create new account
3037        account_id = AccountID(Time.time())
3038        self.__track(0, '', account_id)
3039        new_name = self.name(
3040            account=account_id,
3041            new_name=name,
3042        )
3043        assert name == new_name
3044        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]:
3046    def names(self, keyword: str = '') -> dict[str, AccountID]:
3047        """
3048        Retrieves a dictionary of account IDs and names, optionally filtered by a keyword.
3049
3050        Parameters:
3051        - keyword: An optional string to filter account names. If provided, only accounts whose
3052            names contain the keyword (case-insensitive) will be included in the result.
3053            Defaults to an empty string, which returns all accounts.
3054
3055        Returns:
3056        - A dictionary where keys are account names and values are AccountIDs. The dictionary
3057            contains only accounts that match the provided keyword (if any).
3058        """
3059        return {
3060            account.name: account_id
3061            for account_id, account in self.__vault.account.items()
3062            if keyword.lower() in account.name.lower()
3063        }

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:
3065    def name(self, account: AccountID, new_name: Optional[str] = None) -> str:
3066        """
3067        Retrieves or sets the name of an account.
3068
3069        Parameters:
3070        - account: The AccountID of the account.
3071        - new_name: The new name to set for the account. If None, the current name is retrieved.
3072
3073        Returns:
3074        - The current name of the account if `new_name` is None, or the `new_name` if it is set.
3075
3076        Note: Returns an empty string if the account does not exist.
3077        """
3078        if self.account_exists(account):
3079            if new_name is None:
3080                return self.__vault.account[account].name
3081            assert new_name != ''
3082            no_lock = self.nolock()
3083            lock = self.__lock()
3084            self.__step(Action.NAME, account, value=self.__vault.account[account].name)
3085            self.__vault.account[account].name = new_name
3086            if no_lock:
3087                    assert lock is not None
3088                    self.free(lock)
3089            return new_name
3090        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:
3092    def zakatable(self, account: AccountID, status: Optional[bool] = None) -> bool:
3093        """
3094        Check or set the zakatable status of a specific account.
3095
3096        Parameters:
3097        - account (AccountID): The account reference.
3098        - status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
3099
3100        Returns:
3101        - bool: The current or updated zakatable status of the account.
3102
3103        Raises:
3104        None
3105
3106        Example:
3107        ```bash
3108        >>> tracker = ZakatTracker()
3109        >>> ref = tracker.track(51, 'desc', 'account1')
3110        >>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
3111        True
3112        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
3113        True
3114        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
3115        True
3116        >>> tracker.zakatable('account1', False)
3117        False
3118        ```
3119        """
3120        if self.account_exists(account):
3121            if status is None:
3122                return self.__vault.account[account].zakatable
3123            self.__vault.account[account].zakatable = status
3124            return status
3125        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:
3127    def subtract(self, unscaled_value: float | int | decimal.Decimal, desc: str = '', account: AccountID = AccountID('1'),
3128            created_time_ns: Optional[Timestamp] = None,
3129            debug: bool = False) \
3130            -> SubtractReport:
3131        """
3132        Subtracts a specified value from an account's balance, if the amount to subtract is greater than the account's balance,
3133        the remaining amount will be transferred to a new transaction with a negative value.
3134
3135        Parameters:
3136        - unscaled_value (float | int | decimal.Decimal): The amount to be subtracted.
3137        - desc (str, optional): A description for the transaction. Defaults to an empty string.
3138        - account (AccountID, optional): The account reference from which the value will be subtracted. Defaults to '1'.
3139        - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD).
3140                                           If not provided, the current timestamp will be used.
3141        - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False.
3142
3143        Returns:
3144        - SubtractReport: A class containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
3145
3146        Raises:
3147        - ValueError: The unscaled_value should be greater than zero.
3148        - ValueError: The created_time_ns should be greater than zero.
3149        - ValueError: The box transaction happened again in the same nanosecond time.
3150        - ValueError: The log transaction happened again in the same nanosecond time.
3151        """
3152        if debug:
3153            print('sub', f'debug={debug}')
3154        account = AccountID(account)
3155        if unscaled_value <= 0:
3156            raise ValueError('The unscaled_value should be greater than zero.')
3157        if created_time_ns is None:
3158            created_time_ns = Time.time()
3159        if created_time_ns <= 0:
3160            raise ValueError('The created should be greater than zero.')
3161        no_lock = self.nolock()
3162        lock = self.__lock()
3163        self.__track(0, '', account)
3164        value = self.scale(unscaled_value)
3165        self.__log(value=-value, desc=desc, account=account, created_time_ns=created_time_ns, ref=None, debug=debug)
3166        ids = sorted(self.__vault.account[account].box.keys())
3167        limit = len(ids) + 1
3168        target = value
3169        if debug:
3170            print('ids', ids)
3171        ages = SubtractAges()
3172        for i in range(-1, -limit, -1):
3173            if target == 0:
3174                break
3175            j = ids[i]
3176            if debug:
3177                print('i', i, 'j', j)
3178            rest = self.__vault.account[account].box[j].rest
3179            if rest >= target:
3180                self.__vault.account[account].box[j].rest -= target
3181                self.__step(Action.SUBTRACT, account, ref=j, value=target)
3182                ages.append(SubtractAge(box_ref=j, total=target))
3183                target = 0
3184                break
3185            elif target > rest > 0:
3186                chunk = rest
3187                target -= chunk
3188                self.__vault.account[account].box[j].rest = 0
3189                self.__step(Action.SUBTRACT, account, ref=j, value=chunk)
3190                ages.append(SubtractAge(box_ref=j, total=chunk))
3191        if target > 0:
3192            self.__track(
3193                unscaled_value=self.unscale(-target),
3194                desc=desc,
3195                account=account,
3196                logging=False,
3197                created_time_ns=created_time_ns,
3198            )
3199            ages.append(SubtractAge(box_ref=created_time_ns, total=target))
3200        if no_lock:
3201            assert lock is not None
3202            self.free(lock)
3203        return SubtractReport(
3204            log_ref=created_time_ns,
3205            ages=ages,
3206        )

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]:
3208    def transfer(self, unscaled_amount: float | int | decimal.Decimal, from_account: AccountID, to_account: AccountID, desc: str = '',
3209                 created_time_ns: Optional[Timestamp] = None,
3210                 debug: bool = False) -> Optional[TransferReport]:
3211        """
3212        Transfers a specified value from one account to another.
3213
3214        Parameters:
3215        - unscaled_amount (float | int | decimal.Decimal): The amount to be transferred.
3216        - from_account (AccountID): The account reference from which the value will be transferred.
3217        - to_account (AccountID): The account reference to which the value will be transferred.
3218        - desc (str, optional): A description for the transaction. Defaults to an empty string.
3219        - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, the current timestamp will be used.
3220        - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False.
3221
3222        Returns:
3223        - Optional[TransferReport]: A class of timestamps corresponding to the transactions made during the transfer.
3224
3225        Raises:
3226        - ValueError: Transfer to the same account is forbidden.
3227        - ValueError: The created_time_ns should be greater than zero.
3228        - ValueError: The box transaction happened again in the same nanosecond time.
3229        - ValueError: The log transaction happened again in the same nanosecond time.
3230        """
3231        if debug:
3232            print('transfer', f'debug={debug}')
3233        from_account = AccountID(from_account)
3234        to_account = AccountID(to_account)
3235        if from_account == to_account:
3236            raise ValueError(f'Transfer to the same account is forbidden. {to_account}')
3237        if unscaled_amount <= 0:
3238            return None
3239        if created_time_ns is None:
3240            created_time_ns = Time.time()
3241        if created_time_ns <= 0:
3242            raise ValueError('The created should be greater than zero.')
3243        no_lock = self.nolock()
3244        lock = self.__lock()
3245        subtract_report = self.subtract(unscaled_amount, desc, from_account, created_time_ns, debug=debug)
3246        source_exchange = self.exchange(from_account, created_time_ns)
3247        target_exchange = self.exchange(to_account, created_time_ns)
3248
3249        if debug:
3250            print('ages', subtract_report.ages)
3251
3252        transfer_report = TransferReport()
3253        for subtract in subtract_report.ages:
3254            times = TransferTimes()
3255            age = subtract.box_ref
3256            value = subtract.total
3257            assert source_exchange.rate is not None
3258            assert target_exchange.rate is not None
3259            target_amount = int(self.exchange_calc(value, source_exchange.rate, target_exchange.rate))
3260            if debug:
3261                print('target_amount', target_amount)
3262            # Perform the transfer
3263            if self.box_exists(to_account, age):
3264                if debug:
3265                    print('box_exists', age)
3266                capital = self.__vault.account[to_account].box[age].capital
3267                rest = self.__vault.account[to_account].box[age].rest
3268                if debug:
3269                    print(
3270                        f'Transfer(loop) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).')
3271                selected_age = age
3272                if rest + target_amount > capital:
3273                    self.__vault.account[to_account].box[age].capital += target_amount
3274                    selected_age = Time.time()
3275                self.__vault.account[to_account].box[age].rest += target_amount
3276                self.__step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount)
3277                y = self.__log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account,
3278                              created_time_ns=None, ref=None, debug=debug)
3279                times.append(TransferTime(box_ref=age, log_ref=y))
3280                continue
3281            if debug:
3282                print(
3283                    f'Transfer(func) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).')
3284            box_ref = self.__track(
3285                unscaled_value=self.unscale(int(target_amount)),
3286                desc=desc,
3287                account=to_account,
3288                logging=True,
3289                created_time_ns=age,
3290                debug=debug,
3291            )
3292            transfer_report.append(TransferRecord(
3293                box_ref=box_ref,
3294                times=times,
3295            ))
3296        if no_lock:
3297            assert lock is not None
3298            self.free(lock)
3299        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:
3301    def check(self,
3302              silver_gram_price: float,
3303              unscaled_nisab: Optional[float | int | decimal.Decimal] = None,
3304              debug: bool = False,
3305              created_time_ns: Optional[Timestamp] = None,
3306              cycle: Optional[float] = None) -> ZakatReport:
3307        """
3308        Check the eligibility for Zakat based on the given parameters.
3309
3310        Parameters:
3311        - silver_gram_price (float): The price of a gram of silver.
3312        - unscaled_nisab (float | int | decimal.Decimal, optional): The minimum amount of wealth required for Zakat.
3313                        If not provided, it will be calculated based on the silver_gram_price.
3314        - debug (bool, optional): Flag to enable debug mode.
3315        - created_time_ns (Timestamp, optional): The current timestamp. If not provided, it will be calculated using Time.time().
3316        - cycle (float, optional): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
3317
3318        Returns:
3319        - ZakatReport: A tuple containing a boolean indicating the eligibility for Zakat,
3320            a list of brief statistics, and a dictionary containing the Zakat plan.
3321        """
3322        if debug:
3323            print('check', f'debug={debug}')
3324        before_parameters = {
3325            "silver_gram_price": silver_gram_price,
3326            "unscaled_nisab": unscaled_nisab,
3327            "debug": debug,
3328            "created_time_ns": created_time_ns,
3329            "cycle": cycle,
3330        }
3331        if created_time_ns is None:
3332            created_time_ns = Time.time()
3333        if cycle is None:
3334            cycle = ZakatTracker.TimeCycle()
3335        if unscaled_nisab is None:
3336            unscaled_nisab = ZakatTracker.Nisab(silver_gram_price)
3337        nisab = self.scale(unscaled_nisab)
3338        plan: dict[AccountID, list[BoxPlan]] = {}
3339        summary = ZakatSummary()
3340        below_nisab = 0
3341        valid = False
3342        after_parameters = {
3343            "silver_gram_price": silver_gram_price,
3344            "unscaled_nisab": unscaled_nisab,
3345            "debug": debug,
3346            "created_time_ns": created_time_ns,
3347            "cycle": cycle,
3348        }
3349        if debug:
3350            print('exchanges', self.exchanges())
3351        for x in self.__vault.account:
3352            if not self.zakatable(x):
3353                continue
3354            _box = self.__vault.account[x].box
3355            _log = self.__vault.account[x].log
3356            limit = len(_box) + 1
3357            ids = sorted(self.__vault.account[x].box.keys())
3358            for i in range(-1, -limit, -1):
3359                j = ids[i]
3360                rest = float(_box[j].rest)
3361                if rest <= 0:
3362                    continue
3363                exchange = self.exchange(x, created_time_ns=Time.time())
3364                assert exchange.rate is not None
3365                rest = ZakatTracker.exchange_calc(rest, float(exchange.rate), 1)
3366                summary.num_wealth_items += 1
3367                summary.total_wealth += rest
3368                epoch = (created_time_ns - j) / cycle
3369                if debug:
3370                    print(f'Epoch: {epoch}', _box[j])
3371                if _box[j].zakat.last > 0:
3372                    epoch = (created_time_ns - _box[j].zakat.last) / cycle
3373                if debug:
3374                    print(f'Epoch: {epoch}')
3375                epoch = math.floor(epoch)
3376                if debug:
3377                    print(f'Epoch: {epoch}', type(epoch), epoch == 0, 1 - epoch, epoch)
3378                if epoch == 0:
3379                    continue
3380                if debug:
3381                    print('Epoch - PASSED')
3382                summary.num_zakatable_items += 1
3383                summary.total_zakatable_amount += rest
3384                is_nisab = rest >= nisab
3385                total = 0
3386                if is_nisab:
3387                    for _ in range(epoch):
3388                        total += ZakatTracker.ZakatCut(float(rest) - float(total))
3389                    valid = total > 0
3390                elif rest > 0:
3391                    below_nisab += rest
3392                    total = ZakatTracker.ZakatCut(float(rest))
3393                if total > 0:
3394                    if x not in plan:
3395                        plan[x] = []
3396                    summary.total_zakat_due += total
3397                    plan[x].append(BoxPlan(
3398                        below_nisab=not is_nisab,
3399                        total=total,
3400                        count=epoch,
3401                        ref=j,
3402                        box=_box[j],
3403                        log=_log[j],
3404                        exchange=exchange,
3405                    ))
3406        valid = valid or below_nisab >= nisab
3407        if debug:
3408            print(f'below_nisab({below_nisab}) >= nisab({nisab})')
3409        report = ZakatReport(
3410            created=Time.time(),
3411            valid=valid,
3412            summary=summary,
3413            plan=plan,
3414            parameters={
3415                'before': before_parameters,
3416                'after': after_parameters,
3417            },
3418        )
3419        self.__vault.cache.zakat = report if valid else None
3420        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:
3422    def build_payment_parts(self, scaled_demand: int, positive_only: bool = True) -> PaymentParts:
3423        """
3424        Build payment parts for the Zakat distribution.
3425
3426        Parameters:
3427        - scaled_demand (int): The total demand for payment in local currency.
3428        - positive_only (bool, optional): If True, only consider accounts with positive balance. Default is True.
3429
3430        Returns:
3431        - PaymentParts: A dictionary containing the payment parts for each account. The dictionary has the following structure:
3432        {
3433            'account': {
3434                'account_id': {'balance': float, 'rate': float, 'part': float},
3435                ...
3436            },
3437            'exceed': bool,
3438            'demand': int,
3439            'total': float,
3440        }
3441        """
3442        total = 0.0
3443        parts = PaymentParts(
3444            account={},
3445            exceed=False,
3446            demand=int(round(scaled_demand)),
3447            total=0,
3448        )
3449        for x, y in self.accounts().items():
3450            if positive_only and y.balance <= 0:
3451                continue
3452            total += float(y.balance)
3453            exchange = self.exchange(x)
3454            parts.account[x] = AccountPaymentPart(balance=y.balance, rate=exchange.rate, part=0)
3455        parts.total = total
3456        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:
3458    @staticmethod
3459    def check_payment_parts(parts: PaymentParts, debug: bool = False) -> int:
3460        """
3461        Checks the validity of payment parts.
3462
3463        Parameters:
3464        - parts (dict[str, PaymentParts): A dictionary containing payment parts information.
3465        - debug (bool, optional): Flag to enable debug mode.
3466
3467        Returns:
3468        - int: Returns 0 if the payment parts are valid, otherwise returns the error code.
3469
3470        Error Codes:
3471        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
3472        2: 'balance', 'rate' or 'part' key is missing in parts['account'][x].
3473        3: 'part' value in parts['account'][x] is less than 0.
3474        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
3475        5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
3476        6: The sum of 'part' values in parts['account'] does not match with 'demand' value.
3477        """
3478        if debug:
3479            print('check_payment_parts', f'debug={debug}')
3480        # for i in ['demand', 'account', 'total', 'exceed']:
3481        #     if i not in parts:
3482        #         return 1
3483        exceed = parts.exceed
3484        # for j in ['balance', 'rate', 'part']:
3485        #     if j not in parts.account[x]:
3486        #         return 2
3487        for x in parts.account:
3488            if parts.account[x].part < 0:
3489                return 3
3490            if not exceed and parts.account[x].balance <= 0:
3491                return 4
3492        demand = parts.demand
3493        z = 0.0
3494        for _, y in parts.account.items():
3495            if not exceed and y.part > y.balance:
3496                return 5
3497            z += ZakatTracker.exchange_calc(y.part, y.rate, 1.0)
3498        z = round(z, 2)
3499        demand = round(demand, 2)
3500        if debug:
3501            print('check_payment_parts', f'z = {z}, demand = {demand}')
3502            print('check_payment_parts', type(z), type(demand))
3503            print('check_payment_parts', z != demand)
3504            print('check_payment_parts', str(z) != str(demand))
3505        if z != demand and str(z) != str(demand):
3506            return 6
3507        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:
3509    def zakat(self, report: ZakatReport,
3510        parts: Optional[PaymentParts] = None, debug: bool = False) -> bool:
3511        """
3512        Perform Zakat calculation based on the given report and optional parts.
3513
3514        Parameters:
3515        - report (ZakatReport): A dataclass containing the validity of the report, the report data, and the zakat plan.
3516        - parts (PaymentParts, optional): A dictionary containing the payment parts for the zakat.
3517        - debug (bool, optional): A flag indicating whether to print debug information.
3518
3519        Returns:
3520        - bool: True if the zakat calculation is successful, False otherwise.
3521
3522        Raises:
3523        - AssertionError: Bad Zakat report, call `check` first then call `zakat`.
3524        """
3525        if debug:
3526            print('zakat', f'debug={debug}')
3527        if not report.valid:
3528            return report.valid
3529        assert report.plan
3530        parts_exist = parts is not None
3531        if parts_exist:
3532            if self.check_payment_parts(parts, debug=debug) != 0:
3533                return False
3534        if debug:
3535            print('######### zakat #######')
3536            print('parts_exist', parts_exist)
3537        assert report == self.__vault.cache.zakat, "Bad Zakat report, call `check` first then call `zakat`"
3538        no_lock = self.nolock()
3539        lock = self.__lock()
3540        report_time = Time.time()
3541        self.__vault.report[report_time] = report
3542        self.__step(Action.REPORT, ref=report_time)
3543        created_time_ns = Time.time()
3544        for x in report.plan:
3545            target_exchange = self.exchange(x)
3546            if debug:
3547                print(report.plan[x])
3548                print('-------------')
3549                print(self.__vault.account[x].box)
3550            if debug:
3551                print('plan[x]', report.plan[x])
3552            for plan in report.plan[x]:
3553                j = plan.ref
3554                if debug:
3555                    print('j', j)
3556                assert j
3557                self.__step(Action.ZAKAT, account=x, ref=j, value=self.__vault.account[x].box[j].zakat.last,
3558                           key='last',
3559                           math_operation=MathOperation.EQUAL)
3560                self.__vault.account[x].box[j].zakat.last = created_time_ns
3561                assert target_exchange.rate is not None
3562                amount = ZakatTracker.exchange_calc(float(plan.total), 1, float(target_exchange.rate))
3563                self.__vault.account[x].box[j].zakat.total += amount
3564                self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='total',
3565                           math_operation=MathOperation.ADDITION)
3566                self.__vault.account[x].box[j].zakat.count += plan.count
3567                self.__step(Action.ZAKAT, account=x, ref=j, value=plan.count, key='count',
3568                           math_operation=MathOperation.ADDITION)
3569                if not parts_exist:
3570                    try:
3571                        self.__vault.account[x].box[j].rest -= amount
3572                    except TypeError:
3573                        self.__vault.account[x].box[j].rest -= decimal.Decimal(amount)
3574                    # self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest',
3575                    #            math_operation=MathOperation.SUBTRACTION)
3576                    self.__log(-float(amount), desc='zakat-زكاة', account=x, created_time_ns=None, ref=j, debug=debug)
3577        if parts_exist:
3578            for account, part in parts.account.items():
3579                if part.part == 0:
3580                    continue
3581                if debug:
3582                    print('zakat-part', account, part.rate)
3583                target_exchange = self.exchange(account)
3584                assert target_exchange.rate is not None
3585                amount = ZakatTracker.exchange_calc(part.part, part.rate, target_exchange.rate)
3586                unscaled_amount = self.unscale(int(amount))
3587                if unscaled_amount <= 0:
3588                    if debug:
3589                        print(f"The amount({unscaled_amount:.20f}) it was {amount:.20f} should be greater tha zero.")
3590                    continue
3591                self.subtract(
3592                    unscaled_value=unscaled_amount,
3593                    desc='zakat-part-دفعة-زكاة',
3594                    account=account,
3595                    debug=debug,
3596                )
3597        if no_lock:
3598            assert lock is not None
3599            self.free(lock)
3600        self.__vault.cache.zakat = None
3601        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]:
3603    @staticmethod
3604    def split_at_last_symbol(data: str, symbol: str) -> tuple[str, str]:
3605        """Splits a string at the last occurrence of a given symbol.
3606    
3607        Parameters:
3608        - data (str): The input string.
3609        - symbol (str): The symbol to split at.
3610    
3611        Returns:
3612        - tuple[str, str]: A tuple containing two strings, the part before the last symbol and
3613            the part after the last symbol. If the symbol is not found, returns (data, "").
3614        """
3615        last_symbol_index = data.rfind(symbol)
3616    
3617        if last_symbol_index != -1:
3618            before_symbol = data[:last_symbol_index]
3619            after_symbol = data[last_symbol_index + len(symbol):]
3620            return before_symbol, after_symbol
3621        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:
3623    def save(self, path: Optional[str] = None, hash_required: bool = True) -> bool:
3624        """
3625        Saves the ZakatTracker's current state to a json file.
3626
3627        This method serializes the internal data (`__vault`).
3628
3629        Parameters:
3630        - path (str, optional): File path for saving. Defaults to a predefined location.
3631        - hash_required (bool, optional): Whether to add the data integrity using a hash. Defaults to True.
3632
3633        Returns:
3634        - bool: True if the save operation is successful, False otherwise.
3635        """
3636        if path is None:
3637            path = self.path()
3638        # first save in tmp file
3639        temp = f'{path}.tmp'
3640        try:
3641            with open(temp, 'w', encoding='utf-8') as stream:
3642                data = json.dumps(self.__vault, cls=JSONEncoder)
3643                stream.write(data)
3644                if hash_required:
3645                    hashed = self.hash_data(data.encode())
3646                    stream.write(f'//{hashed}')
3647            # then move tmp file to original location
3648            shutil.move(temp, path)
3649            return True
3650        except (IOError, OSError) as e:
3651            print(f'Error saving file: {e}')
3652            if os.path.exists(temp):
3653                os.remove(temp)
3654            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:
3656    @staticmethod
3657    def load_vault_from_json(json_string: str) -> Vault:
3658        """Loads a Vault dataclass from a JSON string."""
3659        data = json.loads(json_string)
3660
3661        vault = Vault()
3662
3663        # Load Accounts
3664        for account_reference, account_data in data.get("account", {}).items():
3665            account_reference = AccountID(account_reference)
3666            box_data = account_data.get('box', {})
3667            box = {
3668                Timestamp(ts): Box(
3669                    capital=box_data[str(ts)]["capital"],
3670                    rest=box_data[str(ts)]["rest"],
3671                    zakat=BoxZakat(**box_data[str(ts)]["zakat"]),
3672                )
3673                for ts in box_data
3674            }
3675
3676            log_data = account_data.get('log', {})
3677            log = {Timestamp(ts): Log(
3678                value=log_data[str(ts)]['value'],
3679                desc=log_data[str(ts)]['desc'],
3680                ref=Timestamp(log_data[str(ts)].get('ref')) if log_data[str(ts)].get('ref') is not None else None,
3681                file={Timestamp(ft): fv for ft, fv in log_data[str(ts)].get('file', {}).items()},
3682            ) for ts in log_data}
3683
3684            vault.account[account_reference] = Account(
3685                balance=account_data["balance"],
3686                created=Timestamp(account_data["created"]),
3687                name=account_data.get("name", ""),
3688                box=box,
3689                count=account_data.get("count", 0),
3690                log=log,
3691                hide=account_data.get("hide", False),
3692                zakatable=account_data.get("zakatable", True),
3693            )
3694
3695        # Load Exchanges
3696        for account_reference, exchange_data in data.get("exchange", {}).items():
3697            account_reference = AccountID(account_reference)
3698            vault.exchange[account_reference] = {}
3699            for timestamp, exchange_details in exchange_data.items():
3700                vault.exchange[account_reference][Timestamp(timestamp)] = Exchange(
3701                    rate=exchange_details.get("rate"),
3702                    description=exchange_details.get("description"),
3703                    time=Timestamp(exchange_details.get("time")) if exchange_details.get("time") is not None else None,
3704                )
3705
3706        # Load History
3707        for timestamp, history_dict in data.get("history", {}).items():
3708            vault.history[Timestamp(timestamp)] = {}
3709            for history_key, history_data in history_dict.items():
3710                vault.history[Timestamp(timestamp)][Timestamp(history_key)] = History(
3711                    action=Action(history_data["action"]),
3712                    account=AccountID(history_data["account"]) if history_data.get("account") is not None else None,
3713                    ref=Timestamp(history_data.get("ref")) if history_data.get("ref") is not None else None,
3714                    file=Timestamp(history_data.get("file")) if history_data.get("file") is not None else None,
3715                    key=history_data.get("key"),
3716                    value=history_data.get("value"),
3717                    math=MathOperation(history_data.get("math")) if history_data.get("math") is not None else None,
3718                )
3719
3720        # Load Lock
3721        vault.lock = Timestamp(data["lock"]) if data.get("lock") is not None else None
3722
3723        # Load Report
3724        for timestamp, report_data in data.get("report", {}).items():
3725            zakat_plan: dict[AccountID, list[BoxPlan]] = {}
3726            for account_reference, box_plans in report_data.get("plan", {}).items():
3727                account_reference = AccountID(account_reference)
3728                zakat_plan[account_reference] = []
3729                for box_plan_data in box_plans:
3730                    zakat_plan[account_reference].append(BoxPlan(
3731                        box=Box(
3732                            capital=box_plan_data["box"]["capital"],
3733                            rest=box_plan_data["box"]["rest"],
3734                            zakat=BoxZakat(**box_plan_data["box"]["zakat"]),
3735                        ),
3736                        log=Log(**box_plan_data["log"]),
3737                        exchange=Exchange(**box_plan_data["exchange"]),
3738                        below_nisab=box_plan_data["below_nisab"],
3739                        total=box_plan_data["total"],
3740                        count=box_plan_data["count"],
3741                        ref=Timestamp(box_plan_data["ref"]),
3742                    ))
3743
3744            vault.report[Timestamp(timestamp)] = ZakatReport(
3745                created=report_data["created"],
3746                valid=report_data["valid"],
3747                summary=ZakatSummary(**report_data["summary"]),
3748                plan=zakat_plan,
3749                parameters=report_data["parameters"],
3750            )
3751
3752        # Load Cache
3753        vault.cache = Cache()
3754        cache_data = data.get("cache", {})
3755        if "zakat" in cache_data:
3756            cache_zakat_data = cache_data.get("zakat", {})
3757            if cache_zakat_data:
3758                zakat_plan: dict[AccountID, list[BoxPlan]] = {}
3759                for account_reference, box_plans in cache_zakat_data.get("plan", {}).items():
3760                    account_reference = AccountID(account_reference)
3761                    zakat_plan[account_reference] = []
3762                    for box_plan_data in box_plans:
3763                        zakat_plan[account_reference].append(BoxPlan(
3764                            box=Box(
3765                                capital=box_plan_data["box"]["capital"],
3766                                rest=box_plan_data["box"]["rest"],
3767                                zakat=BoxZakat(**box_plan_data["box"]["zakat"]),
3768                            ),
3769                            log=Log(**box_plan_data["log"]),
3770                            exchange=Exchange(**box_plan_data["exchange"]),
3771                            below_nisab=box_plan_data["below_nisab"],
3772                            total=box_plan_data["total"],
3773                            count=box_plan_data["count"],
3774                            ref=Timestamp(box_plan_data["ref"]),
3775                        ))
3776
3777                vault.cache.zakat = ZakatReport(
3778                    created=cache_zakat_data["created"],
3779                    valid=cache_zakat_data["valid"],
3780                    summary=ZakatSummary(**cache_zakat_data["summary"]),
3781                    plan=zakat_plan,
3782                    parameters=cache_zakat_data["parameters"],
3783                )
3784
3785        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:
3787    def load(self, path: Optional[str] = None, hash_required: bool = True, debug: bool = False) -> bool:
3788        """
3789        Load the current state of the ZakatTracker object from a json file.
3790
3791        Parameters:
3792        - path (str, optional): The path where the json file is located. If not provided, it will use the default path.
3793        - hash_required (bool, optional): Whether to verify the data integrity using a hash. Defaults to True.
3794        - debug (bool, optional): Flag to enable debug mode.
3795
3796        Returns:
3797        - bool: True if the load operation is successful, False otherwise.
3798        """
3799        if path is None:
3800            path = self.path()
3801        try:
3802            if os.path.exists(path):
3803                with open(path, 'r', encoding='utf-8') as stream:
3804                    file = stream.read()
3805                    data, hashed = self.split_at_last_symbol(file, '//')
3806                    if hash_required:
3807                        assert hashed
3808                        if debug:
3809                            print('[debug-load]', hashed)
3810                        new_hash = self.hash_data(data.encode())
3811                        if debug:
3812                            print('[debug-load]', new_hash)
3813                        assert hashed == new_hash, "Hash verification failed. File may be corrupted."
3814                    self.__vault = self.load_vault_from_json(data)
3815                return True
3816            else:
3817                print(f'File not found: {path}')
3818                return False
3819        except (IOError, OSError) as e:
3820            print(f'Error loading file: {e}')
3821            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.
@experimental
def import_csv_cache_path(self):
3823    @experimental
3824    def import_csv_cache_path(self):
3825        """
3826        Generates the cache file path for imported CSV data.
3827
3828        This function constructs the file path where cached data from CSV imports
3829        will be stored. The cache file is a json file (.json extension) appended
3830        to the base path of the object.
3831
3832        Parameters:
3833        None
3834
3835        Returns:
3836        - str: The full path to the import CSV cache file.
3837
3838        Example:
3839        ```bash
3840        >>> obj = ZakatTracker('/data/reports')
3841        >>> obj.import_csv_cache_path()
3842        '/data/reports.import_csv.json'
3843        ```
3844        """
3845        path = str(self.path())
3846        ext = self.ext()
3847        ext_len = len(ext)
3848        if path.endswith(f'.{ext}'):
3849            path = path[:-ext_len - 1]
3850        _, filename = os.path.split(path + f'.import_csv.{ext}')
3851        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
@experimental
def get_transaction_csv_headers() -> list[str]:
3853    @staticmethod
3854    @experimental
3855    def get_transaction_csv_headers() -> list[str]:
3856        """
3857        Returns a list of strings representing the headers for a transaction CSV file.
3858
3859        The headers include:
3860        - account: The account associated with the transaction.
3861        - desc: A description of the transaction.
3862        - value: The monetary value of the transaction.
3863        - date: The date of the transaction.
3864        - rate: The applicable rate (if any) for the transaction.
3865        - reference: An optional reference number or identifier for the transaction.
3866
3867        Returns:
3868        - list[str]: A list containing the CSV header strings.
3869        """
3870        return [
3871            "account",
3872            "desc",
3873            "value",
3874            "date",
3875            "rate",
3876            "reference",
3877        ]

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.
@experimental
def import_csv( self, path: str = 'file.csv', scale_decimal_places: int = 0, delimiter: str = ',', debug: bool = False) -> ImportReport:
3879    @experimental
3880    def import_csv(self, path: str = 'file.csv', scale_decimal_places: int = 0, delimiter: str = ',', debug: bool = False) -> ImportReport:
3881        """
3882        The function reads the CSV file, checks for duplicate transactions and tries it's best to creates the transactions history accordingly in the system.
3883
3884        Parameters:
3885        - path (str, optional): The path to the CSV file. Default is 'file.csv'.
3886        - scale_decimal_places (int, optional): The number of decimal places to scale the value. Default is 0.
3887        - delimiter (str, optional): The delimiter character to use in the CSV file. Defaults to ','.
3888        - debug (bool, optional): A flag indicating whether to print debug information.
3889
3890        Returns:
3891        - ImportReport: A dataclass containing the number of transactions created, the number of transactions found in the cache,
3892                and a dictionary of bad transactions.
3893
3894        Notes:
3895        * Currency Pair Assumption: This function assumes that the exchange rates stored for each account
3896                                    are appropriate for the currency pairs involved in the conversions.
3897        * The exchange rate for each account is based on the last encountered transaction rate that is not equal
3898            to 1.0 or the previous rate for that account.
3899        * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent
3900            transactions of the same account within the whole imported and existing dataset when doing `transfer`, `check` and
3901            `zakat` operations.
3902
3903        Example:
3904            The CSV file should have the following format, rate and reference are optionals per transaction:
3905            account, desc, value, date, rate, reference
3906            For example:
3907            safe-45, 'Some text', 34872, 1988-06-30 00:00:00.000000, 1, 6554
3908        """
3909        if debug:
3910            print('import_csv', f'debug={debug}')
3911        cache: list[int] = []
3912        try:
3913            if not self.memory_mode():
3914                with open(self.import_csv_cache_path(), 'r', encoding='utf-8') as stream:
3915                    cache = json.load(stream)
3916        except Exception as e:
3917            if debug:
3918                print(e)
3919        date_formats = [
3920            '%Y-%m-%d %H:%M:%S.%f',
3921            '%Y-%m-%dT%H:%M:%S.%f',
3922            '%Y-%m-%dT%H%M%S.%f',
3923            '%Y-%m-%d',
3924        ]
3925        statistics = ImportStatistics(0, 0, 0)
3926        data: dict[int, list[CSVRecord]] = {}
3927        with open(path, newline='', encoding='utf-8') as f:
3928            i = 0
3929            for row in csv.reader(f, delimiter=delimiter):
3930                if debug:
3931                    print(f"csv_row({i})", row, type(row))
3932                if row == self.get_transaction_csv_headers():
3933                    continue
3934                i += 1
3935                hashed = hash(tuple(row))
3936                if hashed in cache:
3937                    statistics.found += 1
3938                    continue
3939                account = row[0]
3940                desc = row[1]
3941                value = float(row[2])
3942                rate = 1.0
3943                reference = ''
3944                if row[4:5]: # Empty list if index is out of range
3945                    rate = float(row[4])
3946                if row[5:6]:
3947                    reference = row[5]
3948                date: int = 0
3949                for time_format in date_formats:
3950                    try:
3951                        date_str = row[3]
3952                        if "." not in date_str:
3953                            date_str += ".000000"
3954                        date = Time.time(datetime.datetime.strptime(date_str, time_format))
3955                        break
3956                    except Exception as e:
3957                        if debug:
3958                            print(e)
3959                record = CSVRecord(
3960                    index=i,
3961                    account=account,
3962                    desc=desc,
3963                    value=value,
3964                    date=date,
3965                    rate=rate,
3966                    reference=reference,
3967                    hashed=hashed,
3968                    error='',
3969                )
3970                if date <= 0:
3971                    record.error = 'invalid date'
3972                    statistics.bad += 1
3973                if value == 0:
3974                    record.error = 'invalid value'
3975                    statistics.bad += 1
3976                    continue
3977                if date not in data:
3978                    data[date] = []
3979                data[date].append(record)
3980
3981        if debug:
3982            print('import_csv', len(data))
3983
3984        if statistics.bad > 0:
3985            return ImportReport(
3986                statistics=statistics,
3987                bad=[
3988                    item
3989                    for sublist in data.values()
3990                    for item in sublist
3991                    if item.error
3992                ],
3993            )
3994
3995        no_lock = self.nolock()
3996        lock = self.__lock()
3997        names = self.names()
3998
3999        # sync accounts
4000        if debug:
4001            print('before-names', names, len(names))
4002        for date, rows in sorted(data.items()):
4003            new_rows: list[CSVRecord] = []
4004            for row in rows:
4005                if row.account not in names:
4006                    account_id = self.create_account(row.account)
4007                    names[row.account] = account_id
4008                account_id = names[row.account]
4009                assert account_id
4010                row.account = account_id
4011                new_rows.append(row)
4012            assert new_rows
4013            assert date in data
4014            data[date] = new_rows
4015        if debug:
4016            print('after-names', names, len(names))
4017            assert names == self.names()
4018
4019        # do ops
4020        for date, rows in sorted(data.items()):
4021            try:
4022                def process(x: CSVRecord):
4023                    x.value = self.unscale(
4024                        x.value,
4025                        decimal_places=scale_decimal_places,
4026                    ) if scale_decimal_places > 0 else x.value
4027                    if x.rate > 0:
4028                        self.exchange(account=x.account, created_time_ns=x.date, rate=x.rate)
4029                    if x.value > 0:
4030                        self.track(unscaled_value=x.value, desc=x.desc, account=x.account, created_time_ns=x.date)
4031                    elif x.value < 0:
4032                        self.subtract(unscaled_value=-x.value, desc=x.desc, account=x.account, created_time_ns=x.date)
4033                    return x.hashed
4034                len_rows = len(rows)
4035                # If records are found at the same time with different accounts in the same amount
4036                # (one positive and the other negative), this indicates it is a transfer.
4037                if len_rows > 2 or len_rows == 1:
4038                    i = 0
4039                    for row in rows:
4040                        row.date += i
4041                        i += 1
4042                        hashed = process(row)
4043                        assert hashed not in cache
4044                        cache.append(hashed)
4045                        statistics.created += 1
4046                    continue
4047                x1 = rows[0]
4048                x2 = rows[1]
4049                if x1.account == x2.account:
4050                    continue
4051                    # raise Exception(f'invalid transfer')
4052                # not transfer - same time - normal ops
4053                if abs(x1.value) != abs(x2.value) and x1.date == x2.date:
4054                    rows[1].date += 1
4055                    for row in rows:
4056                        hashed = process(row)
4057                        assert hashed not in cache
4058                        cache.append(hashed)
4059                        statistics.created += 1
4060                    continue
4061                if x1.rate > 0:
4062                    self.exchange(x1.account, created_time_ns=x1.date, rate=x1.rate)
4063                if x2.rate > 0:
4064                    self.exchange(x2.account, created_time_ns=x2.date, rate=x2.rate)
4065                x1.value = self.unscale(
4066                    x1.value,
4067                    decimal_places=scale_decimal_places,
4068                ) if scale_decimal_places > 0 else x1.value
4069                x2.value = self.unscale(
4070                    x2.value,
4071                    decimal_places=scale_decimal_places,
4072                ) if scale_decimal_places > 0 else x2.value
4073                # just transfer
4074                values = {
4075                    x1.value: x1.account,
4076                    x2.value: x2.account,
4077                }
4078                if debug:
4079                    print('values', values)
4080                if len(values) <= 1:
4081                    continue
4082                self.transfer(
4083                    unscaled_amount=abs(x1.value),
4084                    from_account=values[min(values.keys())],
4085                    to_account=values[max(values.keys())],
4086                    desc=x1.desc,
4087                    created_time_ns=x1.date,
4088                )
4089            except Exception as e:
4090                for row in rows:
4091                    row.error = str(e)
4092                break
4093        if not self.memory_mode():
4094            with open(self.import_csv_cache_path(), 'w', encoding='utf-8') as stream:
4095                stream.write(json.dumps(cache))
4096        if no_lock:
4097            assert lock is not None
4098            self.free(lock)
4099        report = ImportReport(
4100            statistics=statistics,
4101            bad=[
4102                item
4103                for sublist in data.values()
4104                for item in sublist
4105                if item.error
4106            ],
4107        )
4108        if debug:
4109            debug_path = f'{self.import_csv_cache_path()}.debug.json'
4110            with open(debug_path, 'w', encoding='utf-8') as file:
4111                json.dump(report, file, indent=4, cls=JSONEncoder)
4112                print(f'generated debug report @ `{debug_path}`...')
4113        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:
4119    @staticmethod
4120    def human_readable_size(size: float, decimal_places: int = 2) -> str:
4121        """
4122        Converts a size in bytes to a human-readable format (e.g., KB, MB, GB).
4123
4124        This function iterates through progressively larger units of information
4125        (B, KB, MB, GB, etc.) and divides the input size until it fits within a
4126        range that can be expressed with a reasonable number before the unit.
4127
4128        Parameters:
4129        - size (float): The size in bytes to convert.
4130        - decimal_places (int, optional): The number of decimal places to display
4131            in the result. Defaults to 2.
4132
4133        Returns:
4134        - str: A string representation of the size in a human-readable format,
4135            rounded to the specified number of decimal places. For example:
4136                - '1.50 KB' (1536 bytes)
4137                - '23.00 MB' (24117248 bytes)
4138                - '1.23 GB' (1325899906 bytes)
4139        """
4140        if type(size) not in (float, int):
4141            raise TypeError('size must be a float or integer')
4142        if type(decimal_places) != int:
4143            raise TypeError('decimal_places must be an integer')
4144        for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']:
4145            if size < 1024.0:
4146                break
4147            size /= 1024.0
4148        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:
4150    @staticmethod
4151    def get_dict_size(obj: dict, seen: Optional[set] = None) -> float:
4152        """
4153        Recursively calculates the approximate memory size of a dictionary and its contents in bytes.
4154
4155        This function traverses the dictionary structure, accounting for the size of keys, values,
4156        and any nested objects. It handles various data types commonly found in dictionaries
4157        (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case
4158        of circular references.
4159
4160        Parameters:
4161        - obj (dict): The dictionary whose size is to be calculated.
4162        - seen (set, optional): A set used internally to track visited objects
4163                             and avoid circular references. Defaults to None.
4164
4165        Returns:
4166         - float: An approximate size of the dictionary and its contents in bytes.
4167
4168        Notes:
4169        - This function is a method of the `ZakatTracker` class and is likely used to
4170          estimate the memory footprint of data structures relevant to Zakat calculations.
4171        - The size calculation is approximate as it relies on `sys.getsizeof()`, which might
4172          not account for all memory overhead depending on the Python implementation.
4173        - Circular references are handled to prevent infinite recursion.
4174        - Basic numeric types (int, float, complex) are assumed to have fixed sizes.
4175        - String sizes are estimated based on character length and encoding.
4176        """
4177        size = 0
4178        if seen is None:
4179            seen = set()
4180
4181        obj_id = id(obj)
4182        if obj_id in seen:
4183            return 0
4184
4185        seen.add(obj_id)
4186        size += sys.getsizeof(obj)
4187
4188        if isinstance(obj, dict):
4189            for k, v in obj.items():
4190                size += ZakatTracker.get_dict_size(k, seen)
4191                size += ZakatTracker.get_dict_size(v, seen)
4192        elif isinstance(obj, (list, tuple, set, frozenset)):
4193            for item in obj:
4194                size += ZakatTracker.get_dict_size(item, seen)
4195        elif isinstance(obj, (int, float, complex)):  # Handle numbers
4196            pass  # Basic numbers have a fixed size, so nothing to add here
4197        elif isinstance(obj, str):  # Handle strings
4198            size += len(obj) * sys.getsizeof(str().encode())  # Size per character in bytes
4199        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:
4201    @staticmethod
4202    def day_to_time(day: int, month: int = 6, year: int = 2024) -> Timestamp:  # افتراض أن الشهر هو يونيو والسنة 2024
4203        """
4204        Convert a specific day, month, and year into a timestamp.
4205
4206        Parameters:
4207        - day (int): The day of the month.
4208        - month (int, optional): The month of the year. Default is 6 (June).
4209        - year (int, optional): The year. Default is 2024.
4210
4211        Returns:
4212        - Timestamp: The timestamp representing the given day, month, and year.
4213
4214        Note:
4215        - This method assumes the default month and year if not provided.
4216        """
4217        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:
4219    @staticmethod
4220    def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
4221        """
4222        Generate a random date between two given dates.
4223
4224        Parameters:
4225        - start_date (datetime.datetime): The start date from which to generate a random date.
4226        - end_date (datetime.datetime): The end date until which to generate a random date.
4227
4228        Returns:
4229        - datetime.datetime: A random date between the start_date and end_date.
4230        """
4231        time_between_dates = end_date - start_date
4232        days_between_dates = time_between_dates.days
4233        random_number_of_days = random.randrange(days_between_dates)
4234        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:
4236    @staticmethod
4237    def generate_random_csv_file(path: str = 'data.csv',
4238                                 count: int = 1_000,
4239                                 with_rate: bool = False,
4240                                 delimiter: str = ',',
4241                                 debug: bool = False) -> int:
4242        """
4243        Generate a random CSV file with specified parameters.
4244        The function generates a CSV file at the specified path with the given count of rows.
4245        Each row contains a randomly generated account, description, value, and date.
4246        The value is randomly generated between 1000 and 100000,
4247        and the date is randomly generated between 1950-01-01 and 2023-12-31.
4248        If the row number is not divisible by 13, the value is multiplied by -1.
4249
4250        Parameters:
4251        - path (str, optional): The path where the CSV file will be saved. Default is 'data.csv'.
4252        - count (int, optional): The number of rows to generate in the CSV file. Default is 1000.
4253        - with_rate (bool, optional): If True, a random rate between 1.2% and 12% is added. Default is False.
4254        - delimiter (str, optional): The delimiter character to use in the CSV file. Defaults to ','.
4255        - debug (bool, optional): A flag indicating whether to print debug information.
4256
4257        Returns:
4258        - int: number of generated records.
4259        """
4260        if debug:
4261            print('generate_random_csv_file', f'debug={debug}')
4262        i = 0
4263        with open(path, 'w', newline='', encoding='utf-8') as csvfile:
4264            writer = csv.writer(csvfile, delimiter=delimiter)
4265            writer.writerow(ZakatTracker.get_transaction_csv_headers())
4266            for i in range(count):
4267                account = f'acc-{random.randint(1, count)}'
4268                desc = f'Some text {random.randint(1, count)}'
4269                value = random.randint(1000, 100000)
4270                date = ZakatTracker.generate_random_date(
4271                    datetime.datetime(1000, 1, 1),
4272                    datetime.datetime(2023, 12, 31),
4273                ).strftime('%Y-%m-%d %H:%M:%S.%f' if i % 2 == 0 else '%Y-%m-%d %H:%M:%S')
4274                if not i % 13 == 0:
4275                    value *= -1
4276                row = [account, desc, value, date]
4277                if with_rate:
4278                    rate = random.randint(1, 100) * 0.12
4279                    if debug:
4280                        print('before-append', row)
4281                    row.append(rate)
4282                    if debug:
4283                        print('after-append', row)
4284                if i % 2 == 1:
4285                    row += (Time.time(),)
4286                writer.writerow(row)
4287                i = i + 1
4288        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):
4290    @staticmethod
4291    def create_random_list(max_sum: int, min_value: int = 0, max_value: int = 10):
4292        """
4293        Creates a list of random integers whose sum does not exceed the specified maximum.
4294
4295        Parameters:
4296        - max_sum (int): The maximum allowed sum of the list elements.
4297        - min_value (int, optional): The minimum possible value for an element (inclusive).
4298        - max_value (int, optional): The maximum possible value for an element (inclusive).
4299
4300        Returns:
4301        - A list of random integers.
4302        """
4303        result = []
4304        current_sum = 0
4305
4306        while current_sum < max_sum:
4307            # Calculate the remaining space for the next element
4308            remaining_sum = max_sum - current_sum
4309            # Determine the maximum possible value for the next element
4310            next_max_value = min(remaining_sum, max_value)
4311            # Generate a random element within the allowed range
4312            next_element = random.randint(min_value, next_max_value)
4313            result.append(next_element)
4314            current_sum += next_element
4315
4316        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]:
4318    def backup(self, folder_path: str, output_directory: str = "compressed", debug: bool = False) -> Optional[Backup]:
4319        """
4320        Compresses a folder into a .tar.lzma archive.
4321
4322        The archive is named following a specific format:
4323        'zakatdb_v<version>_<YYYYMMDD_HHMMSS>_<sha1hash>.tar.lzma'.  This format
4324        is crucial for the `restore` function, so avoid renaming the files.
4325
4326        Parameters:
4327        - folder_path (str): The path to the folder to be compressed.
4328        - output_directory (str, optional): The directory to save the compressed file.
4329                                        Defaults to "compressed".
4330        - debug (bool, optional): Whether to print debug information. Default is False.
4331
4332        Returns:
4333        - Optional[Backup]: A Backup object containing the path to the created archive
4334                            and its SHA1 hash on success, None on failure.
4335        """
4336        try:
4337            os.makedirs(output_directory, exist_ok=True)
4338            now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
4339
4340            # Create a temporary tar archive in memory to calculate the hash
4341            tar_buffer = io.BytesIO()
4342            with tarfile.open(fileobj=tar_buffer, mode="w") as tar:
4343                tar.add(folder_path, arcname=os.path.basename(folder_path))
4344            tar_buffer.seek(0)
4345            folder_hash = hashlib.sha1(tar_buffer.read()).hexdigest()
4346            output_filename = f"zakatdb_v{self.Version()}_{now}_{folder_hash}.tar.lzma"
4347            output_path = os.path.join(output_directory, output_filename)
4348
4349            # Compress the folder to the final .tar.lzma file
4350            with lzma.open(output_path, "wb") as lzma_file:
4351                tar_buffer.seek(0)  # Reset the buffer
4352                with tarfile.open(fileobj=lzma_file, mode="w") as tar:
4353                    tar.add(folder_path, arcname=os.path.basename(folder_path))
4354
4355            if debug:
4356                print(f"Folder '{folder_path}' has been compressed to '{output_path}'")
4357            return Backup(
4358                path=output_path,
4359                hash=folder_hash,
4360            )
4361        except Exception as e:
4362            print(f"Error during compression: {e}")
4363            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:
4365    def restore(self, tar_lzma_path: str, output_folder_path: str = "uncompressed", debug: bool = False) -> bool:
4366        """
4367        Uncompresses a .tar.lzma archive and verifies its integrity using the SHA1 hash.
4368
4369        The SHA1 hash is extracted from the archive's filename, which must follow
4370        the format: 'zakatdb_v<version>_<YYYYMMDD_HHMMSS>_<sha1hash>.tar.lzma'.
4371        This format is essential for successful restoration.
4372
4373        Parameters:
4374        - tar_lzma_path (str): The path to the .tar.lzma file.
4375        - output_folder_path (str, optional): The directory to extract the contents to.
4376                                            Defaults to "uncompressed".
4377        - debug (bool, optional): Whether to print debug information. Default is False.
4378        
4379        Returns:
4380        - bool: True if the restoration was successful and the hash matches, False otherwise.
4381        """
4382        try:
4383            output_folder_path = pathlib.Path(output_folder_path).resolve()
4384            os.makedirs(output_folder_path, exist_ok=True)
4385            filename = os.path.basename(tar_lzma_path)
4386            re_match = re.match(r"zakatdb_v([^_]+)_(\d{8}_\d{6})_([a-f0-9]{40})\.tar\.lzma", filename)
4387            if not re_match:
4388                if debug:
4389                    print(f"Error: Invalid filename format: '{filename}'")
4390                return False
4391
4392            expected_hash_from_filename = re_match.group(3)
4393
4394            with lzma.open(tar_lzma_path, "rb") as lzma_file:
4395                tar_buffer = io.BytesIO(lzma_file.read())  # Read the entire decompressed tar into memory
4396                with tarfile.open(fileobj=tar_buffer, mode="r") as tar:
4397                    tar.extractall(output_folder_path)
4398                    tar_buffer.seek(0)  # Reset buffer to calculate hash
4399                    extracted_hash = hashlib.sha1(tar_buffer.read()).hexdigest()
4400
4401            new_path = os.path.join(output_folder_path, get_first_directory_inside(output_folder_path))
4402            assert os.path.exists(os.path.join(new_path, f"db.{self.ext()}")), f"Restored db.{self.ext()} not found."
4403            if extracted_hash == expected_hash_from_filename:
4404                if debug:
4405                    print(f"'{filename}' has been successfully uncompressed to '{output_folder_path}' and hash verified from filename.")
4406                now = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
4407                old_path = os.path.dirname(self.path())
4408                tmp_path = os.path.join(os.path.dirname(old_path), "tmp_restore", now)
4409                if debug:
4410                    print('[xxx] - old_path:', old_path)
4411                    print('[xxx] - tmp_path:', tmp_path)
4412                    print('[xxx] - new_path:', new_path)
4413                try:
4414                    shutil.move(old_path, tmp_path)
4415                    shutil.move(new_path, old_path)
4416                    assert self.load()
4417                    shutil.rmtree(tmp_path)
4418                    return True
4419                except Exception as e:
4420                    print(f"Error applying the restored files: {e}")
4421                    shutil.move(tmp_path, old_path)
4422                    return False
4423            else:
4424                if debug:
4425                    print(f"Warning: Hash mismatch after uncompressing '{filename}'. Expected from filename: {expected_hash_from_filename}, Got: {extracted_hash}")
4426                # Optionally remove the extracted folder if the hash doesn't match
4427                # shutil.rmtree(output_folder_path, ignore_errors=True)
4428                return False
4429
4430        except Exception as e:
4431            print(f"Error during uncompression or hash check: {e}")
4432            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:
4931    def test(self, debug: bool = False) -> bool:
4932        if debug:
4933            print('test', f'debug={debug}')
4934        try:
4935
4936            self._test_timeline(debug)
4937            self._test_core(True, debug)
4938            self._test_core(False, debug)
4939
4940            # test_names
4941            self.reset()
4942            x = "test_names"
4943            failed = False
4944            try:
4945                assert self.name(x) == ''
4946            except:
4947                failed = True
4948            assert failed
4949            assert self.names() == {}
4950            failed = False
4951            try:
4952                assert self.name(x, 'qwe') == ''
4953            except:
4954                failed = True
4955            assert failed
4956            account_id0 = self.create_account(x)
4957            assert isinstance(account_id0, AccountID)
4958            assert int(account_id0) > 0
4959            assert self.name(account_id0) == x
4960            assert self.name(account_id0, 'qwe') == 'qwe'
4961            if debug:
4962                print(self.names(keyword='qwe'))
4963            assert self.names(keyword='asd') == {}
4964            assert self.names(keyword='qwe') == {'qwe': account_id0}
4965
4966            # test_create_account
4967            account_name = "test_account"
4968            assert self.names(keyword=account_name) == {}
4969            account_id = self.create_account(account_name)
4970            assert isinstance(account_id, AccountID)
4971            assert int(account_id) > 0
4972            assert account_id in self.__vault.account
4973            assert self.name(account_id) == account_name
4974            assert self.names(keyword=account_name) == {account_name: account_id}
4975
4976            failed = False
4977            try:
4978                self.create_account(account_name)
4979            except:
4980                failed = True
4981            assert failed
4982
4983            # bad are names is forbidden
4984
4985            for bad_name in [
4986                None,
4987                '',
4988                Time.time(),
4989                -Time.time(),
4990                f'{Time.time()}',
4991                f'{-Time.time()}',
4992                0.0,
4993                '0.0',
4994                ' ',
4995            ]:
4996                failed = False
4997                try:
4998                    self.create_account(bad_name)
4999                except:
5000                    failed = True
5001                assert failed
5002
5003            # rename account
5004            assert self.name(account_id) == account_name
5005            assert self.name(account_id, 'asd') == 'asd'
5006            assert self.name(account_id) == 'asd'
5007            # use old and not used name
5008            account_id2 = self.create_account(account_name)
5009            assert int(account_id2) > 0
5010            assert account_id != account_id2
5011            assert self.name(account_id2) == account_name
5012            assert self.names(keyword=account_name) == {account_name: account_id2}
5013
5014            assert self.__history()
5015            count = len(self.__vault.history)
5016            if debug:
5017                print('history-count', count)
5018            assert count == 8
5019
5020            assert self.recall(dry=False, debug=debug)
5021            assert self.name(account_id2) == ''
5022            assert self.account_exists(account_id2)
5023            assert self.recall(dry=False, debug=debug)
5024            assert not self.account_exists(account_id2)
5025            assert self.recall(dry=False, debug=debug)
5026            assert self.name(account_id) == account_name
5027            assert self.recall(dry=False, debug=debug)
5028            assert self.account_exists(account_id)
5029            assert self.recall(dry=False, debug=debug)
5030            assert not self.account_exists(account_id)
5031            assert self.names(keyword='qwe') == {'qwe': account_id0}
5032            assert self.recall(dry=False, debug=debug)
5033            assert self.names(keyword='qwe') == {}
5034            assert self.name(account_id0) == x
5035            assert self.recall(dry=False, debug=debug)
5036            assert self.name(account_id0) == ''
5037            assert self.account_exists(account_id0)
5038            assert self.recall(dry=False, debug=debug)
5039            assert not self.account_exists(account_id0)
5040            assert not self.recall(dry=False, debug=debug)
5041
5042            # Not allowed for duplicate transactions in the same account and time
5043
5044            created = Time.time()
5045            same_account_id = self.create_account('same')
5046            self.track(100, 'test-1', same_account_id, True, created)
5047            failed = False
5048            try:
5049                self.track(50, 'test-1', same_account_id, True, created)
5050            except:
5051                failed = True
5052            assert failed is True
5053
5054            self.reset()
5055
5056            # Same account transfer
5057            for x in [1, 'a', True, 1.8, None]:
5058                failed = False
5059                try:
5060                    self.transfer(1, x, x, 'same-account', debug=debug)
5061                except:
5062                    failed = True
5063                assert failed is True
5064
5065            # Always preserve box age during transfer
5066
5067            series: list[tuple[int, int]] = [
5068                (30, 4),
5069                (60, 3),
5070                (90, 2),
5071            ]
5072            case = {
5073                3000: {
5074                    'series': series,
5075                    'rest': 15000,
5076                },
5077                6000: {
5078                    'series': series,
5079                    'rest': 12000,
5080                },
5081                9000: {
5082                    'series': series,
5083                    'rest': 9000,
5084                },
5085                18000: {
5086                    'series': series,
5087                    'rest': 0,
5088                },
5089                27000: {
5090                    'series': series,
5091                    'rest': -9000,
5092                },
5093                36000: {
5094                    'series': series,
5095                    'rest': -18000,
5096                },
5097            }
5098
5099            selected_time = Time.time() - ZakatTracker.TimeCycle()
5100            ages_account_id = self.create_account('ages')
5101            future_account_id = self.create_account('future')
5102
5103            for total in case:
5104                if debug:
5105                    print('--------------------------------------------------------')
5106                    print(f'case[{total}]', case[total])
5107                for x in case[total]['series']:
5108                    self.track(
5109                        unscaled_value=x[0],
5110                        desc=f'test-{x} ages',
5111                        account=ages_account_id,
5112                        created_time_ns=selected_time * x[1],
5113                    )
5114
5115                unscaled_total = self.unscale(total)
5116                if debug:
5117                    print('unscaled_total', unscaled_total)
5118                refs = self.transfer(
5119                    unscaled_amount=unscaled_total,
5120                    from_account=ages_account_id,
5121                    to_account=future_account_id,
5122                    desc='Zakat Movement',
5123                    debug=debug,
5124                )
5125
5126                if debug:
5127                    print('refs', refs)
5128
5129                ages_cache_balance = self.balance(ages_account_id)
5130                ages_fresh_balance = self.balance(ages_account_id, False)
5131                rest = case[total]['rest']
5132                if debug:
5133                    print('source', ages_cache_balance, ages_fresh_balance, rest)
5134                assert ages_cache_balance == rest
5135                assert ages_fresh_balance == rest
5136
5137                future_cache_balance = self.balance(future_account_id)
5138                future_fresh_balance = self.balance(future_account_id, False)
5139                if debug:
5140                    print('target', future_cache_balance, future_fresh_balance, total)
5141                    print('refs', refs)
5142                assert future_cache_balance == total
5143                assert future_fresh_balance == total
5144
5145                # TODO: check boxes times for `ages` should equal box times in `future`
5146                for ref in self.__vault.account[ages_account_id].box:
5147                    ages_capital = self.__vault.account[ages_account_id].box[ref].capital
5148                    ages_rest = self.__vault.account[ages_account_id].box[ref].rest
5149                    future_capital = 0
5150                    if ref in self.__vault.account[future_account_id].box:
5151                        future_capital = self.__vault.account[future_account_id].box[ref].capital
5152                    future_rest = 0
5153                    if ref in self.__vault.account[future_account_id].box:
5154                        future_rest = self.__vault.account[future_account_id].box[ref].rest
5155                    if ages_capital != 0 and future_capital != 0 and future_rest != 0:
5156                        if debug:
5157                            print('================================================================')
5158                            print('ages', ages_capital, ages_rest)
5159                            print('future', future_capital, future_rest)
5160                        if ages_rest == 0:
5161                            assert ages_capital == future_capital
5162                        elif ages_rest < 0:
5163                            assert -ages_capital == future_capital
5164                        elif ages_rest > 0:
5165                            assert ages_capital == ages_rest + future_capital
5166                self.reset()
5167                assert len(self.__vault.history) == 0
5168
5169            assert self.__history()
5170            assert self.__history(False) is False
5171            assert self.__history() is False
5172            assert self.__history(True)
5173            assert self.__history()
5174            if debug:
5175                print('####################################################################')
5176
5177            wallet_account_id = self.create_account('wallet')
5178            safe_account_id = self.create_account('safe')
5179            bank_account_id = self.create_account('bank')
5180            transaction = [
5181                (
5182                    20, wallet_account_id, AccountID(1), -2000, -2000, -2000, 1, 1,
5183                    2000, 2000, 2000, 1, 1,
5184                ),
5185                (
5186                    750, wallet_account_id, safe_account_id, -77000, -77000, -77000, 2, 2,
5187                    75000, 75000, 75000, 1, 1,
5188                ),
5189                (
5190                    600, safe_account_id, bank_account_id, 15000, 15000, 15000, 1, 2,
5191                    60000, 60000, 60000, 1, 1,
5192                ),
5193            ]
5194            for z in transaction:
5195                lock = self.lock()
5196                x = z[1]
5197                y = z[2]
5198                self.transfer(
5199                    unscaled_amount=z[0],
5200                    from_account=x,
5201                    to_account=y,
5202                    desc='test-transfer',
5203                    debug=debug,
5204                )
5205                zz = self.balance(x)
5206                if debug:
5207                    print(zz, z)
5208                assert zz == z[3]
5209                xx = self.accounts()[x]
5210                assert xx.balance == z[3]
5211                assert self.balance(x, False) == z[4]
5212                assert xx.balance == z[4]
5213
5214                s = 0
5215                log = self.__vault.account[x].log
5216                for i in log:
5217                    s += log[i].value
5218                if debug:
5219                    print('s', s, 'z[5]', z[5])
5220                assert s == z[5]
5221
5222                assert self.box_size(x) == z[6]
5223                assert self.log_size(x) == z[7]
5224
5225                yy = self.accounts()[y]
5226                assert self.balance(y) == z[8]
5227                assert yy.balance == z[8]
5228                assert self.balance(y, False) == z[9]
5229                assert yy.balance == z[9]
5230
5231                s = 0
5232                log = self.__vault.account[y].log
5233                for i in log:
5234                    s += log[i].value
5235                assert s == z[10]
5236
5237                assert self.box_size(y) == z[11]
5238                assert self.log_size(y) == z[12]
5239                assert lock is not None
5240                assert self.free(lock)
5241
5242            assert self.nolock()
5243            history_count = len(self.__vault.history)
5244            transaction_count = len(transaction)
5245            if debug:
5246                print('history-count', history_count, transaction_count)
5247            assert history_count == transaction_count * 3
5248            assert not self.free(Time.time())
5249            assert self.free(self.lock())
5250            assert self.nolock()
5251            assert len(self.__vault.history) == transaction_count * 3
5252
5253            # recall
5254
5255            assert self.nolock()
5256            for i in range(transaction_count * 3, 0, -1):
5257                assert len(self.__vault.history) == i
5258                assert self.recall(dry=False, debug=debug) is True
5259            assert len(self.__vault.history) == 0
5260            assert self.recall(dry=False, debug=debug) is False
5261            assert len(self.__vault.history) == 0
5262
5263            # exchange
5264
5265            cash_account_id = self.create_account('cash')
5266            self.exchange(cash_account_id, 25, 3.75, '2024-06-25')
5267            self.exchange(cash_account_id, 22, 3.73, '2024-06-22')
5268            self.exchange(cash_account_id, 15, 3.69, '2024-06-15')
5269            self.exchange(cash_account_id, 10, 3.66)
5270
5271            assert self.nolock()
5272
5273            bank_account_id = self.create_account('bank')
5274            for i in range(1, 30):
5275                exchange = self.exchange(cash_account_id, i)
5276                rate, description, created = exchange.rate, exchange.description, exchange.time
5277                if debug:
5278                    print(i, rate, description, created)
5279                assert created
5280                if i < 10:
5281                    assert rate == 1
5282                    assert description is None
5283                elif i == 10:
5284                    assert rate == 3.66
5285                    assert description is None
5286                elif i < 15:
5287                    assert rate == 3.66
5288                    assert description is None
5289                elif i == 15:
5290                    assert rate == 3.69
5291                    assert description is not None
5292                elif i < 22:
5293                    assert rate == 3.69
5294                    assert description is not None
5295                elif i == 22:
5296                    assert rate == 3.73
5297                    assert description is not None
5298                elif i >= 25:
5299                    assert rate == 3.75
5300                    assert description is not None
5301                exchange = self.exchange(bank_account_id, i)
5302                rate, description, created = exchange.rate, exchange.description, exchange.time
5303                if debug:
5304                    print(i, rate, description, created)
5305                assert created
5306                assert rate == 1
5307                assert description is None
5308
5309            assert len(self.__vault.exchange) == 1
5310            assert len(self.exchanges()) == 1
5311            self.__vault.exchange.clear()
5312            assert len(self.__vault.exchange) == 0
5313            assert len(self.exchanges()) == 0
5314            self.reset()
5315
5316            # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية
5317            cash_account_id = self.create_account('cash')
5318            self.exchange(cash_account_id, ZakatTracker.day_to_time(25), 3.75, '2024-06-25')
5319            self.exchange(cash_account_id, ZakatTracker.day_to_time(22), 3.73, '2024-06-22')
5320            self.exchange(cash_account_id, ZakatTracker.day_to_time(15), 3.69, '2024-06-15')
5321            self.exchange(cash_account_id, ZakatTracker.day_to_time(10), 3.66)
5322
5323            assert self.nolock()
5324
5325            test_account_id = self.create_account('test')
5326            for i in [x * 0.12 for x in range(-15, 21)]:
5327                if i <= 0:
5328                    assert self.exchange(test_account_id, Time.time(), i, f'range({i})') == Exchange()
5329                else:
5330                    assert self.exchange(test_account_id, Time.time(), i, f'range({i})') is not Exchange()
5331
5332            assert self.nolock()
5333
5334           # اختبار النتائج باستخدام التواريخ بالنانو ثانية
5335            bank_account_id = self.create_account('bank')
5336            for i in range(1, 31):
5337                timestamp_ns = ZakatTracker.day_to_time(i)
5338                exchange = self.exchange(cash_account_id, timestamp_ns)
5339                rate, description, created = exchange.rate, exchange.description, exchange.time
5340                if debug:
5341                    print(i, rate, description, created)
5342                assert created
5343                if i < 10:
5344                    assert rate == 1
5345                    assert description is None
5346                elif i == 10:
5347                    assert rate == 3.66
5348                    assert description is None
5349                elif i < 15:
5350                    assert rate == 3.66
5351                    assert description is None
5352                elif i == 15:
5353                    assert rate == 3.69
5354                    assert description is not None
5355                elif i < 22:
5356                    assert rate == 3.69
5357                    assert description is not None
5358                elif i == 22:
5359                    assert rate == 3.73
5360                    assert description is not None
5361                elif i >= 25:
5362                    assert rate == 3.75
5363                    assert description is not None
5364                exchange = self.exchange(bank_account_id, i)
5365                rate, description, created = exchange.rate, exchange.description, exchange.time
5366                if debug:
5367                    print(i, rate, description, created)
5368                assert created
5369                assert rate == 1
5370                assert description is None
5371
5372            assert self.nolock()
5373            if debug:
5374                print(self.__vault.history, len(self.__vault.history))
5375            for _ in range(len(self.__vault.history)):
5376                assert self.recall(dry=False, debug=debug)
5377            assert not self.recall(dry=False, debug=debug)
5378
5379            self.reset()
5380
5381            # test transfer between accounts with different exchange rate
5382
5383            a_SAR = self.create_account('Bank (SAR)')
5384            b_USD = self.create_account('Bank (USD)')
5385            c_SAR = self.create_account('Safe (SAR)')
5386            # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer
5387            for case in [
5388                (0, a_SAR, 'SAR Gift', 1000, 100000),
5389                (1, a_SAR, 1),
5390                (0, b_USD, 'USD Gift', 500, 50000),
5391                (1, b_USD, 1),
5392                (2, b_USD, 3.75),
5393                (1, b_USD, 3.75),
5394                (3, 100, b_USD, a_SAR, '100 USD -> SAR', 40000, 137500),
5395                (0, c_SAR, 'Salary', 750, 75000),
5396                (3, 375, c_SAR, b_USD, '375 SAR -> USD', 37500, 50000),
5397                (3, 3.75, a_SAR, b_USD, '3.75 SAR -> USD', 137125, 50100),
5398            ]:
5399                if debug:
5400                    print('case', case)
5401                match (case[0]):
5402                    case 0:  # track
5403                        _, account, desc, x, balance = case
5404                        self.track(unscaled_value=x, desc=desc, account=account, debug=debug)
5405
5406                        cached_value = self.balance(account, cached=True)
5407                        fresh_value = self.balance(account, cached=False)
5408                        if debug:
5409                            print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value)
5410                        assert cached_value == balance
5411                        assert fresh_value == balance
5412                    case 1:  # check-exchange
5413                        _, account, expected_rate = case
5414                        t_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug)
5415                        if debug:
5416                            print('t-exchange', t_exchange)
5417                        assert t_exchange.rate == expected_rate
5418                    case 2:  # do-exchange
5419                        _, account, rate = case
5420                        self.exchange(account, rate=rate, debug=debug)
5421                        b_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug)
5422                        if debug:
5423                            print('b-exchange', b_exchange)
5424                        assert b_exchange.rate == rate
5425                    case 3:  # transfer
5426                        _, x, a, b, desc, a_balance, b_balance = case
5427                        self.transfer(x, a, b, desc, debug=debug)
5428
5429                        cached_value = self.balance(a, cached=True)
5430                        fresh_value = self.balance(a, cached=False)
5431                        if debug:
5432                            print(
5433                                'account', a,
5434                                'cached_value', cached_value,
5435                                'fresh_value', fresh_value,
5436                                'a_balance', a_balance,
5437                            )
5438                        assert cached_value == a_balance
5439                        assert fresh_value == a_balance
5440
5441                        cached_value = self.balance(b, cached=True)
5442                        fresh_value = self.balance(b, cached=False)
5443                        if debug:
5444                            print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value)
5445                        assert cached_value == b_balance
5446                        assert fresh_value == b_balance
5447
5448            # Transfer all in many chunks randomly from B to A
5449            a_SAR_balance = 137125
5450            b_USD_balance = 50100
5451            b_USD_exchange = self.exchange(b_USD)
5452            amounts = ZakatTracker.create_random_list(b_USD_balance, max_value=1000)
5453            if debug:
5454                print('amounts', amounts)
5455            i = 0
5456            for x in amounts:
5457                if debug:
5458                    print(f'{i} - transfer-with-exchange({x})')
5459                self.transfer(
5460                    unscaled_amount=self.unscale(x),
5461                    from_account=b_USD,
5462                    to_account=a_SAR,
5463                    desc=f'{x} USD -> SAR',
5464                    debug=debug,
5465                )
5466
5467                b_USD_balance -= x
5468                cached_value = self.balance(b_USD, cached=True)
5469                fresh_value = self.balance(b_USD, cached=False)
5470                if debug:
5471                    print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
5472                          b_USD_balance)
5473                assert cached_value == b_USD_balance
5474                assert fresh_value == b_USD_balance
5475
5476                a_SAR_balance += int(x * b_USD_exchange.rate)
5477                cached_value = self.balance(a_SAR, cached=True)
5478                fresh_value = self.balance(a_SAR, cached=False)
5479                if debug:
5480                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
5481                          a_SAR_balance, 'rate', b_USD_exchange.rate)
5482                assert cached_value == a_SAR_balance
5483                assert fresh_value == a_SAR_balance
5484                i += 1
5485
5486            # Transfer all in many chunks randomly from C to A
5487            c_SAR_balance = 37500
5488            amounts = ZakatTracker.create_random_list(c_SAR_balance, max_value=1000)
5489            if debug:
5490                print('amounts', amounts)
5491            i = 0
5492            for x in amounts:
5493                if debug:
5494                    print(f'{i} - transfer-with-exchange({x})')
5495                self.transfer(
5496                    unscaled_amount=self.unscale(x),
5497                    from_account=c_SAR,
5498                    to_account=a_SAR,
5499                    desc=f'{x} SAR -> a_SAR',
5500                    debug=debug,
5501                )
5502
5503                c_SAR_balance -= x
5504                cached_value = self.balance(c_SAR, cached=True)
5505                fresh_value = self.balance(c_SAR, cached=False)
5506                if debug:
5507                    print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
5508                          c_SAR_balance)
5509                assert cached_value == c_SAR_balance
5510                assert fresh_value == c_SAR_balance
5511
5512                a_SAR_balance += x
5513                cached_value = self.balance(a_SAR, cached=True)
5514                fresh_value = self.balance(a_SAR, cached=False)
5515                if debug:
5516                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
5517                          a_SAR_balance)
5518                assert cached_value == a_SAR_balance
5519                assert fresh_value == a_SAR_balance
5520                i += 1
5521
5522            assert self.save(f'accounts-transfer-with-exchange-rates.{self.ext()}')
5523
5524            # check & zakat with exchange rates for many cycles
5525
5526            lock = None
5527            safe_account_id = self.create_account('safe')
5528            cave_account_id = self.create_account('cave')
5529            for rate, values in {
5530                1: {
5531                    'in': [1000, 2000, 10000],
5532                    'exchanged': [100000, 200000, 1000000],
5533                    'out': [2500, 5000, 73140],
5534                },
5535                3.75: {
5536                    'in': [200, 1000, 5000],
5537                    'exchanged': [75000, 375000, 1875000],
5538                    'out': [1875, 9375, 137138],
5539                },
5540            }.items():
5541                a, b, c = values['in']
5542                m, n, o = values['exchanged']
5543                x, y, z = values['out']
5544                if debug:
5545                    print('rate', rate, 'values', values)
5546                for case in [
5547                    (a, safe_account_id, Time.time() - ZakatTracker.TimeCycle(), [
5548                        {safe_account_id: {0: {'below_nisab': x}}},
5549                    ], False, m),
5550                    (b, safe_account_id, Time.time() - ZakatTracker.TimeCycle(), [
5551                        {safe_account_id: {0: {'count': 1, 'total': y}}},
5552                    ], True, n),
5553                    (c, cave_account_id, Time.time() - (ZakatTracker.TimeCycle() * 3), [
5554                        {cave_account_id: {0: {'count': 3, 'total': z}}},
5555                    ], True, o),
5556                ]:
5557                    if debug:
5558                        print(f'############# check(rate: {rate}) #############')
5559                        print('case', case)
5560                    self.reset()
5561                    self.exchange(account=case[1], created_time_ns=case[2], rate=rate)
5562                    self.track(
5563                        unscaled_value=case[0],
5564                        desc='test-check',
5565                        account=case[1],
5566                        created_time_ns=case[2],
5567                    )
5568                    assert self.snapshot()
5569
5570                    # assert self.nolock()
5571                    # history_size = len(self.__vault.history)
5572                    # print('history_size', history_size)
5573                    # assert history_size == 2
5574                    lock = self.lock()
5575                    assert lock
5576                    assert not self.nolock()
5577                    assert self.__vault.cache.zakat is None
5578                    report = self.check(2.17, None, debug)
5579                    if debug:
5580                        print('[report]', report)
5581                    assert case[4] == report.valid
5582                    assert case[5] == report.summary.total_wealth
5583                    assert case[5] == report.summary.total_zakatable_amount
5584                    if report.valid:
5585                        assert self.__vault.cache.zakat is not None
5586                        assert report.plan
5587                        assert self.zakat(report, debug=debug)
5588                        assert self.__vault.cache.zakat is None
5589                        if debug:
5590                            pp().pprint(self.__vault)
5591                        self._test_storage(debug=debug)
5592
5593                        for x in report.plan:
5594                            assert case[1] == x
5595                            if report.plan[x][0].below_nisab:
5596                                if debug:
5597                                    print('[assert]', report.plan[x][0].total, case[3][0][x][0]['below_nisab'])
5598                                assert report.plan[x][0].total == case[3][0][x][0]['below_nisab']
5599                            else:
5600                                if debug:
5601                                    print('[assert]', int(report.summary.total_zakat_due), case[3][0][x][0]['total'])
5602                                    print('[assert]', int(report.plan[x][0].total), case[3][0][x][0]['total'])
5603                                    print('[assert]', report.plan[x][0].count ,case[3][0][x][0]['count'])
5604                                assert int(report.summary.total_zakat_due) == case[3][0][x][0]['total']
5605                                assert int(report.plan[x][0].total) == case[3][0][x][0]['total']
5606                                assert report.plan[x][0].count == case[3][0][x][0]['count']
5607                    else:
5608                        assert self.__vault.cache.zakat is None
5609                        result = self.zakat(report, debug=debug)
5610                        if debug:
5611                            print('zakat-result', result, case[4])
5612                        assert result == case[4]
5613                        report = self.check(2.17, None, debug)
5614                        assert report.valid is False
5615            self._test_storage(account_id=cave_account_id, debug=debug)
5616
5617            # recall after zakat
5618
5619            history_size = len(self.__vault.history)
5620            if debug:
5621                print('history_size', history_size)
5622            assert history_size == 3
5623            assert not self.nolock()
5624            assert self.recall(dry=False, debug=debug) is False
5625            self.free(lock)
5626            assert self.nolock()
5627
5628            for i in range(3, 0, -1):
5629                history_size = len(self.__vault.history)
5630                if debug:
5631                    print('history_size', history_size)
5632                assert history_size == i
5633                assert self.recall(dry=False, debug=debug) is True
5634
5635            assert self.nolock()
5636            assert self.recall(dry=False, debug=debug) is False
5637
5638            history_size = len(self.__vault.history)
5639            if debug:
5640                print('history_size', history_size)
5641            assert history_size == 0
5642
5643            account_size = len(self.__vault.account)
5644            if debug:
5645                print('account_size', account_size)
5646            assert account_size == 0
5647
5648            report_size = len(self.__vault.report)
5649            if debug:
5650                print('report_size', report_size)
5651            assert report_size == 0
5652
5653            assert self.nolock()
5654
5655            # csv
5656
5657            csv_count = 1000
5658
5659            for with_rate, path in {
5660                False: 'test-import_csv-no-exchange',
5661                True: 'test-import_csv-with-exchange',
5662            }.items():
5663
5664                if debug:
5665                    print('test_import_csv', with_rate, path)
5666
5667                csv_path = path + '.csv'
5668                if os.path.exists(csv_path):
5669                    os.remove(csv_path)
5670                c = self.generate_random_csv_file(
5671                    path=csv_path,
5672                    count=csv_count,
5673                    with_rate=with_rate,
5674                    debug=debug,
5675                )
5676                if debug:
5677                    print('generate_random_csv_file', c)
5678                assert c == csv_count
5679                assert os.path.getsize(csv_path) > 0
5680                cache_path = self.import_csv_cache_path()
5681                if os.path.exists(cache_path):
5682                    os.remove(cache_path)
5683                self.reset()
5684                lock = self.lock()
5685                import_report = self.import_csv(csv_path, debug=debug)
5686                bad_count = len(import_report.bad)
5687                if debug:
5688                    print(f'csv-imported: {import_report.statistics} = count({csv_count})')
5689                    print('bad', import_report.bad)
5690                assert import_report.statistics.created + import_report.statistics.found + bad_count == csv_count
5691                assert import_report.statistics.created == csv_count
5692                assert bad_count == 0
5693                assert bad_count == import_report.statistics.bad
5694                tmp_size = os.path.getsize(cache_path)
5695                assert tmp_size > 0
5696
5697                import_report_2 = self.import_csv(csv_path, debug=debug)
5698                bad_2_count = len(import_report_2.bad)
5699                if debug:
5700                    print(f'csv-imported: {import_report_2}')
5701                    print('bad', import_report_2.bad)
5702                assert tmp_size == os.path.getsize(cache_path)
5703                assert import_report_2.statistics.created + import_report_2.statistics.found + bad_2_count == csv_count
5704                assert import_report.statistics.created == import_report_2.statistics.found
5705                assert bad_count == bad_2_count
5706                assert import_report_2.statistics.found == csv_count
5707                assert bad_2_count == 0
5708                assert bad_2_count == import_report_2.statistics.bad
5709                assert import_report_2.statistics.created == 0
5710
5711                # payment parts
5712
5713                positive_parts = self.build_payment_parts(100, positive_only=True)
5714                assert self.check_payment_parts(positive_parts) != 0
5715                assert self.check_payment_parts(positive_parts) != 0
5716                all_parts = self.build_payment_parts(300, positive_only=False)
5717                assert self.check_payment_parts(all_parts) != 0
5718                assert self.check_payment_parts(all_parts) != 0
5719                if debug:
5720                    pp().pprint(positive_parts)
5721                    pp().pprint(all_parts)
5722                # dynamic discount
5723                suite = []
5724                count = 3
5725                for exceed in [False, True]:
5726                    case = []
5727                    for part in [positive_parts, all_parts]:
5728                        #part = parts.copy()
5729                        demand = part.demand
5730                        if debug:
5731                            print(demand, part.total)
5732                        i = 0
5733                        z = demand / count
5734                        cp = PaymentParts(
5735                            demand=demand,
5736                            exceed=exceed,
5737                            total=part.total,
5738                        )
5739                        j = ''
5740                        for x, y in part.account.items():
5741                            x_exchange = self.exchange(x)
5742                            zz = self.exchange_calc(z, 1, x_exchange.rate)
5743                            if exceed and zz <= demand:
5744                                i += 1
5745                                y.part = zz
5746                                if debug:
5747                                    print(exceed, y)
5748                                cp.account[x] = y
5749                                case.append(y)
5750                            elif not exceed and y.balance >= zz:
5751                                i += 1
5752                                y.part = zz
5753                                if debug:
5754                                    print(exceed, y)
5755                                cp.account[x] = y
5756                                case.append(y)
5757                            j = x
5758                            if i >= count:
5759                                break
5760                        if debug:
5761                            print('[debug]', j)
5762                            print('[debug]', cp.account[j])
5763                        if cp.account[j] != AccountPaymentPart(0.0, 0.0, 0.0):
5764                            suite.append(cp)
5765                if debug:
5766                    print('suite', len(suite))
5767                for case in suite:
5768                    if debug:
5769                        print('case', case)
5770                    result = self.check_payment_parts(case)
5771                    if debug:
5772                        print('check_payment_parts', result, f'exceed: {exceed}')
5773                    assert result == 0
5774
5775                    assert self.__vault.cache.zakat is None
5776                    report = self.check(2.17, None, debug)
5777                    if debug:
5778                        print('valid', report.valid)
5779                    zakat_result = self.zakat(report, parts=case, debug=debug)
5780                    if debug:
5781                        print('zakat-result', zakat_result)
5782                    assert report.valid == zakat_result
5783                    # test verified zakat report is required
5784                    if zakat_result:
5785                        assert self.__vault.cache.zakat is None
5786                        failed = False
5787                        try:
5788                            self.zakat(report, parts=case, debug=debug)
5789                        except:
5790                            failed = True
5791                        assert failed
5792
5793                assert self.free(lock)
5794
5795            assert self.save(path + f'.{self.ext()}')
5796            assert self.save(f'1000-transactions-test.{self.ext()}')
5797            return True
5798        except Exception as e:
5799            if self.__debug_output:
5800                pp().pprint(self.__vault)
5801                print('============================================================================')
5802                pp().pprint(self.__debug_output)
5803            assert self.save(f'test-snapshot.{self.ext()}')
5804            raise e
class AccountID(builtins.str):
274class AccountID(str):
275    """
276    A class representing an Account ID, which is a string that must be a positive integer greater than zero.
277    Inherits from str, so it behaves like a string.
278    """
279
280    def __new__(cls, value):
281        """
282        Creates a new AccountID instance.
283
284        Parameters:
285        - value (str): The string value to be used as the AccountID.
286
287        Raises:
288        - ValueError: If the provided value is not a valid AccountID.
289
290        Returns:
291        - AccountID: A new AccountID instance.
292        """
293        if isinstance(value, Timestamp):
294            value = str(value) # convert timestamp to string
295        if not cls.is_valid_account_id(value):
296            raise ValueError(f"Invalid AccountID: '{value}'")
297        return super().__new__(cls, value)
298
299    @staticmethod
300    def is_valid_account_id(s: str) -> bool:
301        """
302        Checks if a string is a valid AccountID (positive integer greater than zero).
303
304        Parameters:
305        - s (str): The string to check.
306
307        Returns:
308         - bool: True if the string is a valid AccountID, False otherwise.
309        """
310        if not s:
311            return False
312
313        try:
314            if s[0] == '0':
315                return False
316            if s.startswith('-'):
317                return False
318            if not s.isdigit():
319                return False
320        except:
321            pass
322
323        try:
324            num = int(s)
325            return num > 0
326        except ValueError:
327            return False
328
329    @classmethod
330    def test(cls, debug: bool = False):
331        """
332        Runs tests for the AccountID class to ensure it behaves correctly.
333
334        This method tests various valid and invalid input strings to verify that:
335            - Valid AccountIDs are created successfully.
336            - Invalid AccountIDs raise ValueError exceptions.
337        """
338        test_data = {
339            "123": True,
340            "0": False,
341            "01": False,
342            "-1": False,
343            "abc": False,
344            "12.3": False,
345            "": False,
346            "9999999999999999999999999999999999999": True,
347            "1": True,
348            "10": True,
349            "000000000000000001": False,
350            " ": False,
351            "1 ": False,
352            " 1": False,
353            "1.0": False,
354            Timestamp(12345): True, # Test timestamp input
355        }
356
357        for input_value, expected_output in test_data.items():
358            if expected_output:
359                try:
360                    account_id = cls(input_value)
361                    if debug:
362                        print(f'"{str(account_id)}", "{input_value}"')
363                    if isinstance(input_value, Timestamp):
364                        input_value = str(input_value)
365                    assert str(account_id) == input_value, f"Test failed for valid input: '{input_value}'"
366                except ValueError as e:
367                    assert False, f"Unexpected ValueError for valid input: '{input_value}': {e}"
368            else:
369                try:
370                    cls(input_value)
371                    assert False, f"Expected ValueError for invalid input: '{input_value}'"
372                except ValueError as e:
373                    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)
280    def __new__(cls, value):
281        """
282        Creates a new AccountID instance.
283
284        Parameters:
285        - value (str): The string value to be used as the AccountID.
286
287        Raises:
288        - ValueError: If the provided value is not a valid AccountID.
289
290        Returns:
291        - AccountID: A new AccountID instance.
292        """
293        if isinstance(value, Timestamp):
294            value = str(value) # convert timestamp to string
295        if not cls.is_valid_account_id(value):
296            raise ValueError(f"Invalid AccountID: '{value}'")
297        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:
299    @staticmethod
300    def is_valid_account_id(s: str) -> bool:
301        """
302        Checks if a string is a valid AccountID (positive integer greater than zero).
303
304        Parameters:
305        - s (str): The string to check.
306
307        Returns:
308         - bool: True if the string is a valid AccountID, False otherwise.
309        """
310        if not s:
311            return False
312
313        try:
314            if s[0] == '0':
315                return False
316            if s.startswith('-'):
317                return False
318            if not s.isdigit():
319                return False
320        except:
321            pass
322
323        try:
324            num = int(s)
325            return num > 0
326        except ValueError:
327            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):
329    @classmethod
330    def test(cls, debug: bool = False):
331        """
332        Runs tests for the AccountID class to ensure it behaves correctly.
333
334        This method tests various valid and invalid input strings to verify that:
335            - Valid AccountIDs are created successfully.
336            - Invalid AccountIDs raise ValueError exceptions.
337        """
338        test_data = {
339            "123": True,
340            "0": False,
341            "01": False,
342            "-1": False,
343            "abc": False,
344            "12.3": False,
345            "": False,
346            "9999999999999999999999999999999999999": True,
347            "1": True,
348            "10": True,
349            "000000000000000001": False,
350            " ": False,
351            "1 ": False,
352            " 1": False,
353            "1.0": False,
354            Timestamp(12345): True, # Test timestamp input
355        }
356
357        for input_value, expected_output in test_data.items():
358            if expected_output:
359                try:
360                    account_id = cls(input_value)
361                    if debug:
362                        print(f'"{str(account_id)}", "{input_value}"')
363                    if isinstance(input_value, Timestamp):
364                        input_value = str(input_value)
365                    assert str(account_id) == input_value, f"Test failed for valid input: '{input_value}'"
366                except ValueError as e:
367                    assert False, f"Unexpected ValueError for valid input: '{input_value}': {e}"
368            else:
369                try:
370                    cls(input_value)
371                    assert False, f"Expected ValueError for invalid input: '{input_value}'"
372                except ValueError as e:
373                    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:
376@dataclasses.dataclass
377class AccountDetails:
378    """
379    Details of an account.
380
381    Attributes:
382    - account_id: The unique identifier (ID) of the account.
383    - account_name: Human-readable name of the account.
384    - balance: The current cached balance of the account.
385    """
386    account_id: AccountID
387    account_name: str
388    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):
212class Timestamp(int):
213    """Represents a timestamp as an integer, which must be greater than zero."""
214
215    def __new__(cls, value):
216        """
217        Creates a new Timestamp instance.
218
219        Parameters:
220        - value (int or str): The integer value to be used as the timestamp.
221
222        Raises:
223        - TypeError: If the provided value is not an integer or a string representing an integer.
224        - ValueError: If the provided value is not greater than zero.
225
226        Returns:
227        - Timestamp: A new Timestamp instance.
228        """
229        if isinstance(value, str):
230            try:
231                value = int(value)
232            except ValueError:
233                raise TypeError(f"String value must represent an integer, instead ({type(value)}: {value}) is given.")
234        if not isinstance(value, int):
235            raise TypeError(f"Timestamp value must be an integer, instead ({type(value)}: {value}) is given.")
236
237        if value <= 0:
238            raise ValueError("Timestamp value must be greater than zero.")
239
240        return super().__new__(cls, value)
241
242    @classmethod
243    def test(cls):
244        """
245        Runs tests for the Timestamp class to ensure it behaves correctly.
246        """
247        test_data = {
248            123: True,
249            "123": True,
250            0: False,
251            "0": False,
252            -1: False,
253            "-1": False,
254            "abc": False,
255            1: True,
256            "1": True,
257        }
258
259        for input_value, expected_output in test_data.items():
260            if expected_output:
261                try:
262                    timestamp = cls(input_value)
263                    assert int(timestamp) == int(input_value), f"Test failed for valid input: '{input_value}'"
264                except (TypeError, ValueError) as e:
265                    assert False, f"Unexpected error for valid input: '{input_value}': {e}"
266            else:
267                try:
268                    cls(input_value)
269                    assert False, f"Expected error for invalid input: '{input_value}'"
270                except (TypeError, ValueError):
271                    pass  # Expected exception

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

Timestamp(value)
215    def __new__(cls, value):
216        """
217        Creates a new Timestamp instance.
218
219        Parameters:
220        - value (int or str): The integer value to be used as the timestamp.
221
222        Raises:
223        - TypeError: If the provided value is not an integer or a string representing an integer.
224        - ValueError: If the provided value is not greater than zero.
225
226        Returns:
227        - Timestamp: A new Timestamp instance.
228        """
229        if isinstance(value, str):
230            try:
231                value = int(value)
232            except ValueError:
233                raise TypeError(f"String value must represent an integer, instead ({type(value)}: {value}) is given.")
234        if not isinstance(value, int):
235            raise TypeError(f"Timestamp value must be an integer, instead ({type(value)}: {value}) is given.")
236
237        if value <= 0:
238            raise ValueError("Timestamp value must be greater than zero.")
239
240        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):
242    @classmethod
243    def test(cls):
244        """
245        Runs tests for the Timestamp class to ensure it behaves correctly.
246        """
247        test_data = {
248            123: True,
249            "123": True,
250            0: False,
251            "0": False,
252            -1: False,
253            "-1": False,
254            "abc": False,
255            1: True,
256            "1": True,
257        }
258
259        for input_value, expected_output in test_data.items():
260            if expected_output:
261                try:
262                    timestamp = cls(input_value)
263                    assert int(timestamp) == int(input_value), f"Test failed for valid input: '{input_value}'"
264                except (TypeError, ValueError) as e:
265                    assert False, f"Unexpected error for valid input: '{input_value}': {e}"
266            else:
267                try:
268                    cls(input_value)
269                    assert False, f"Expected error for invalid input: '{input_value}'"
270                except (TypeError, ValueError):
271                    pass  # Expected exception

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

@dataclasses.dataclass
class Box(zakat.StrictDataclass):
484@dataclasses.dataclass
485class Box(
486        StrictDataclass,
487        # ImmutableWithSelectiveFreeze,
488    ):
489    """
490    Represents a financial box with capital, remaining value, and zakat details.
491
492    Attributes:
493    - capital (int): The initial capital value of the box.
494    - rest (int): The current remaining value within the box.
495    - zakat (BoxZakat): A `BoxZakat` object containing the accumulated zakat information for the box.
496    """
497    capital: int #= dataclasses.field(metadata={"frozen": True})
498    rest: int
499    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):
502@dataclasses.dataclass
503class Log(StrictDataclass):
504    """
505    Represents a log entry for an account.
506
507    Attributes:
508    - value: The value of the log entry.
509    - desc: A description of the log entry.
510    - ref: An optional timestamp reference.
511    - file: A dictionary mapping timestamps to file paths.
512    """
513    value: int
514    desc: str
515    ref: Optional[Timestamp]
516    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):
519@dataclasses.dataclass
520class Account(StrictDataclass):
521    """
522    Represents a financial account.
523
524    Attributes:
525    - balance: The current balance of the account.
526    - created: The timestamp when the account was created.
527    - name: The name of the account.
528    - box: A dictionary mapping timestamps to Box objects.
529    - count: A counter for logs, initialized to 0.
530    - log: A dictionary mapping timestamps to Log objects.
531    - hide: A boolean indicating whether the account is hidden.
532    - zakatable: A boolean indicating whether the account is subject to zakat.
533    """
534    balance: int
535    created: Timestamp
536    name: str = ''
537    box: dict[Timestamp, Box] = dataclasses.field(default_factory=dict)
538    count: int = dataclasses.field(default_factory=factory_value(0))
539    log: dict[Timestamp, Log] = dataclasses.field(default_factory=dict)
540    hide: bool = dataclasses.field(default_factory=factory_value(False))
541    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):
544@dataclasses.dataclass
545class Exchange(StrictDataclass):
546    """
547    Represents an exchange rate and related information.
548
549    Attributes:
550    - rate: The exchange rate (optional).
551    - description: A description of the exchange (optional).
552    - time: The timestamp of the exchange (optional).
553    """
554    rate: Optional[float] = None
555    description: Optional[str] = None
556    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):
559@dataclasses.dataclass
560class History(StrictDataclass):
561    """
562    Represents a history entry for an account action.
563
564    Attributes:
565    - action: The action performed.
566    - account: The ID of the account (optional).
567    - ref: An optional timestamp reference.
568    - file: An optional timestamp for a file.
569    - key: An optional key.
570    - value: An optional value.
571    - math: An optional math operation.
572    """
573    action: Action
574    account: Optional[AccountID]
575    ref: Optional[Timestamp]
576    file: Optional[Timestamp]
577    key: Optional[str]
578    value: Optional[any] # !!!
579    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):
654@dataclasses.dataclass
655class Vault(StrictDataclass):
656    """
657    Represents a vault containing accounts, exchanges, and history.
658
659    Attributes:
660    - account: A dictionary mapping account IDs to Account objects.
661    - exchange: A dictionary mapping account IDs to dictionaries of timestamps and Exchange objects.
662    - history: A dictionary mapping timestamps to dictionaries of History objects.
663    - lock: An optional timestamp for a lock.
664    - report: A dictionary mapping timestamps to tuples.
665    - cache: A Cache object containing cached Zakat-related data.
666    """
667    account: dict[AccountID, Account] = dataclasses.field(default_factory=dict)
668    exchange: dict[AccountID, dict[Timestamp, Exchange]] = dataclasses.field(default_factory=dict)
669    history: dict[Timestamp, dict[Timestamp, History]] = dataclasses.field(default_factory=dict)
670    lock: Optional[Timestamp] = None
671    report: dict[Timestamp, ZakatReport] = dataclasses.field(default_factory=dict)
672    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):
675@dataclasses.dataclass
676class AccountPaymentPart(StrictDataclass):
677    """
678    Represents a payment part for an account.
679
680    Attributes:
681    - balance: The balance of the payment part.
682    - rate: The rate of the payment part.
683    - part: The part of the payment.
684    """
685    balance: float
686    rate: float
687    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):
690@dataclasses.dataclass
691class PaymentParts(StrictDataclass):
692    """
693    Represents payment parts for multiple accounts.
694
695    Attributes:
696    - exceed: A boolean indicating whether the payment exceeds a limit.
697    - demand: The demand for payment.
698    - total: The total payment.
699    - account: A dictionary mapping account references to AccountPaymentPart objects.
700    """
701    exceed: bool
702    demand: int
703    total: float
704    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):
707@dataclasses.dataclass
708class SubtractAge(StrictDataclass):
709    """
710    Represents an age subtraction.
711
712    Attributes:
713    - box_ref: The timestamp reference for the box.
714    - total: The total amount to subtract.
715    """
716    box_ref: Timestamp
717    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]):
720@dataclasses.dataclass
721class SubtractAges(StrictDataclass, list[SubtractAge]):
722    """A list of SubtractAge objects."""
723    pass

A list of SubtractAge objects.

@dataclasses.dataclass
class SubtractReport(zakat.StrictDataclass):
726@dataclasses.dataclass
727class SubtractReport(StrictDataclass):
728    """
729    Represents a report of age subtractions.
730
731    Attributes:
732    - log_ref: The timestamp reference for the log.
733    - ages: A list of SubtractAge objects.
734    """
735    log_ref: Timestamp
736    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):
739@dataclasses.dataclass
740class TransferTime(StrictDataclass):
741    """
742    Represents a transfer time.
743
744    Attributes:
745    - box_ref: The timestamp reference for the box.
746    - log_ref: The timestamp reference for the log.
747    """
748    box_ref: Timestamp
749    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]):
752@dataclasses.dataclass
753class TransferTimes(StrictDataclass, list[TransferTime]):
754    """A list of TransferTime objects."""
755    pass

A list of TransferTime objects.

@dataclasses.dataclass
class TransferRecord(zakat.StrictDataclass):
758@dataclasses.dataclass
759class TransferRecord(StrictDataclass):
760    """
761    Represents a transfer record.
762
763    Attributes:
764    - box_ref: The timestamp reference for the box.
765    - times: A list of TransferTime objects.
766    """
767    box_ref: Timestamp
768    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]):
771class TransferReport(StrictDataclass, list[TransferRecord]):
772    """A list of TransferRecord objects."""
773    pass

A list of TransferRecord objects.

@dataclasses.dataclass
class BoxPlan(zakat.StrictDataclass):
582@dataclasses.dataclass
583class BoxPlan(StrictDataclass):
584    """
585    Represents a plan for a box.
586
587    Attributes:
588    - box: The Box object.
589    - log: The Log object.
590    - exchange: The Exchange object.
591    - below_nisab: A boolean indicating whether the value is below nisab.
592    - total: The total value.
593    - count: The count.
594    - ref: The timestamp reference for related Box & Log.
595    """
596    box: Box
597    log: Log
598    exchange: Exchange
599    below_nisab: bool
600    total: float
601    count: int
602    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):
605@dataclasses.dataclass
606class ZakatSummary(StrictDataclass):
607    """
608    Summarizes key financial figures for a Zakat calculation.
609
610    Attributes:
611    - total_wealth (int): The total wealth collected from all rest of transactions.
612    - num_wealth_items (int): The number of individual transactions contributing to the total wealth.
613    - num_zakatable_items (int): The number of transactions subject to Zakat.
614    - total_zakatable_amount (int): The total value of all transactions subject to Zakat.
615    - total_zakat_due (int): The calculated amount of Zakat payable.
616    """
617    total_wealth: int = 0
618    num_wealth_items: int = 0
619    num_zakatable_items: int = 0
620    total_zakatable_amount: int = 0
621    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):
624@dataclasses.dataclass
625class ZakatReport(StrictDataclass):
626    """
627    Represents a Zakat report containing the calculation summary, plan, and parameters.
628
629    Attributes:
630    - created: The timestamp when the report was created.
631    - valid: A boolean indicating whether the Zakat is available.
632    - summary: The ZakatSummary object.
633    - plan: A dictionary mapping account IDs to lists of BoxPlan objects.
634    - parameters: A dictionary holding the input parameters used during the Zakat calculation.
635    """
636    created: Timestamp
637    valid: bool
638    summary: ZakatSummary
639    plan: dict[AccountID, list[BoxPlan]]
640    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):
5807def test(path: Optional[str] = None, debug: bool = False):
5808    """
5809    Executes a test suite for the ZakatTracker.
5810
5811    This function initializes a ZakatTracker instance, optionally using a specified
5812    database path or a temporary directory. It then runs the test suite and, if debug
5813    mode is enabled, prints detailed test results and execution time.
5814
5815    Parameters:
5816    - path (str, optional): The path to the ZakatTracker database. If None, a
5817                            temporary directory is created. Defaults to None.
5818    - debug (bool, optional): Enables debug mode, which prints detailed test
5819                            results and execution time. Defaults to False.
5820
5821    Returns:
5822    None. The function asserts the result of the ZakatTracker's test suite.
5823
5824    Raises:
5825    - AssertionError: If the ZakatTracker's test suite fails.
5826
5827    Examples:
5828    - `test()` Runs tests using a temporary database.
5829    - `test(debug=True)` Runs the test suite in debug mode with a temporary directory.
5830    - `test(path="/path/to/my/db")` Runs tests using a specified database path.
5831    - `test(path="/path/to/my/db", debug=False)` Runs test suite with specified path.
5832    """
5833    no_path = path is None
5834    if no_path:
5835        path = tempfile.mkdtemp()
5836        print(f"Random database path {path}")
5837    if os.path.exists(path):
5838        shutil.rmtree(path)
5839    assert ZakatTracker(':memory:').memory_mode()
5840    ledger = ZakatTracker(
5841        db_path=path,
5842        history_mode=True,
5843    )
5844    start = time.time_ns()
5845    assert not ledger.memory_mode()
5846    assert ledger.test(debug=debug)
5847    if no_path and os.path.exists(path):
5848        shutil.rmtree(path)
5849    if debug:
5850        print('#########################')
5851        print('######## TEST DONE ########')
5852        print('#########################')
5853        print(Time.duration_from_nanoseconds(time.time_ns() - start))
5854        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):
148@enum.unique
149class Action(enum.Enum):
150    """
151    Enumeration representing various actions that can be performed.
152
153    Members:
154    - CREATE: Represents the creation action ('CREATE').
155    - NAME: Represents the renaming action ('NAME').
156    - TRACK: Represents the tracking action ('TRACK').
157    - LOG: Represents the logging action ('LOG').
158    - SUBTRACT: Represents the subtract action ('SUBTRACT').
159    - ADD_FILE: Represents the action of adding a file ('ADD_FILE').
160    - REMOVE_FILE: Represents the action of removing a file ('REMOVE_FILE').
161    - BOX_TRANSFER: Represents the action of transferring a box ('BOX_TRANSFER').
162    - EXCHANGE: Represents the exchange action ('EXCHANGE').
163    - REPORT: Represents the reporting action ('REPORT').
164    - ZAKAT: Represents a Zakat related action ('ZAKAT').
165    """
166    CREATE = 'CREATE'
167    NAME = 'NAME'
168    TRACK = 'TRACK'
169    LOG = 'LOG'
170    SUBTRACT = 'SUBTRACT'
171    ADD_FILE = 'ADD_FILE'
172    REMOVE_FILE = 'REMOVE_FILE'
173    BOX_TRANSFER = 'BOX_TRANSFER'
174    EXCHANGE = 'EXCHANGE'
175    REPORT = 'REPORT'
176    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):
926class JSONEncoder(json.JSONEncoder):
927    """
928    Custom JSON encoder to handle specific object types.
929
930    This encoder overrides the default `default` method to serialize:
931    - `Action` and `MathOperation` enums as their member names.
932    - `decimal.Decimal` instances as floats.
933
934    Example:
935    ```bash
936    >>> json.dumps(Action.CREATE, cls=JSONEncoder)
937    'CREATE'
938    >>> json.dumps(decimal.Decimal('10.5'), cls=JSONEncoder)
939    '10.5'
940    ```
941    """
942    def default(self, o):
943        """
944        Overrides the default `default` method to serialize specific object types.
945
946        Parameters:
947        - o: The object to serialize.
948
949        Returns:
950        - The serialized object.
951        """
952        if isinstance(o, (Action, MathOperation)):
953            return o.name  # Serialize as the enum member's name
954        if isinstance(o, decimal.Decimal):
955            return float(o)
956        if isinstance(o, Exception):
957            return str(o)
958        if isinstance(o, Vault) or isinstance(o, ImportReport):
959            return dataclasses.asdict(o)
960        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):
942    def default(self, o):
943        """
944        Overrides the default `default` method to serialize specific object types.
945
946        Parameters:
947        - o: The object to serialize.
948
949        Returns:
950        - The serialized object.
951        """
952        if isinstance(o, (Action, MathOperation)):
953            return o.name  # Serialize as the enum member's name
954        if isinstance(o, decimal.Decimal):
955            return float(o)
956        if isinstance(o, Exception):
957            return str(o)
958        if isinstance(o, Vault) or isinstance(o, ImportReport):
959            return dataclasses.asdict(o)
960        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):
963class JSONDecoder(json.JSONDecoder):
964    """
965    Custom JSON decoder to handle specific object types.
966
967    This decoder overrides the `object_hook` method to deserialize:
968    - Strings representing enum member names back to their respective enum values.
969    - Floats back to `decimal.Decimal` instances.
970
971    Example:
972    ```bash
973    >>> json.loads('{"action": "CREATE"}', cls=JSONDecoder)
974    {'action': <Action.CREATE: 1>}
975    >>> json.loads('{"value": 10.5}', cls=JSONDecoder)
976    {'value': Decimal('10.5')}
977    ```
978    """
979    def object_hook(self, obj):
980        """
981        Overrides the default `object_hook` method to deserialize specific object types.
982
983        Parameters:
984        - obj: The object to deserialize.
985
986        Returns:
987        - The deserialized object.
988        """
989        if isinstance(obj, str) and obj in Action.__members__:
990            return Action[obj]
991        if isinstance(obj, str) and obj in MathOperation.__members__:
992            return MathOperation[obj]
993        if isinstance(obj, float):
994            return decimal.Decimal(str(obj))
995        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):
979    def object_hook(self, obj):
980        """
981        Overrides the default `object_hook` method to deserialize specific object types.
982
983        Parameters:
984        - obj: The object to deserialize.
985
986        Returns:
987        - The deserialized object.
988        """
989        if isinstance(obj, str) and obj in Action.__members__:
990            return Action[obj]
991        if isinstance(obj, str) and obj in MathOperation.__members__:
992            return MathOperation[obj]
993        if isinstance(obj, float):
994            return decimal.Decimal(str(obj))
995        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):
179@enum.unique
180class MathOperation(enum.Enum):
181    """
182    Enumeration representing mathematical operations.
183
184    Members:
185    - ADDITION: Represents the addition operation ('ADDITION').
186    - EQUAL: Represents the equality operation ('EQUAL').
187    - SUBTRACTION: Represents the subtraction operation ('SUBTRACTION').
188    """
189    ADDITION = 'ADDITION'
190    EQUAL = 'EQUAL'
191    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):
125@enum.unique
126class WeekDay(enum.Enum):
127    """
128    Enumeration representing the days of the week.
129
130    Members:
131    - MONDAY: Represents Monday (0).
132    - TUESDAY: Represents Tuesday (1).
133    - WEDNESDAY: Represents Wednesday (2).
134    - THURSDAY: Represents Thursday (3).
135    - FRIDAY: Represents Friday (4).
136    - SATURDAY: Represents Saturday (5).
137    - SUNDAY: Represents Sunday (6).
138    """
139    MONDAY = 0
140    TUESDAY = 1
141    WEDNESDAY = 2
142    THURSDAY = 3
143    FRIDAY = 4
144    SATURDAY = 5
145    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:
398@dataclasses.dataclass
399class StrictDataclass:
400    """A dataclass that prevents setting non-existent attributes."""
401    def __setattr__(self, name: str, value: any) -> None:
402        _check_attribute(self, name, value)

A dataclass that prevents setting non-existent attributes.

class ImmutableWithSelectiveFreeze:
405class ImmutableWithSelectiveFreeze:
406    """
407    A base class for creating immutable objects with the ability to selectively
408    freeze specific fields.
409
410    Inheriting from this class will automatically make all fields defined in
411    dataclasses as frozen after initialization if their metadata contains
412    `"frozen": True`. Attempting to set a value to a frozen field after
413    initialization will raise a RuntimeError.
414
415    Example:
416    ```python
417    @dataclasses.dataclass
418    class MyObject(ImmutableWithSelectiveFreeze):
419        name: str
420        count: int = dataclasses.field(metadata={"frozen": True})
421        description: str = "default"
422
423    obj = MyObject(name="Test", count=5)
424    print(obj.name)  # Output: Test
425    print(obj.count) # Output: 5
426    obj.name = "New Name" # This will work
427    try:
428        obj.count = 10  # This will raise a RuntimeError
429    except RuntimeError as e:
430        print(e)      # Output: Field 'count' is frozen!
431    print(obj.description) # Output: default
432    obj.description = "updated" # This will work
433    ```
434    """
435    # Implementation based on: https://discuss.python.org/t/dataclasses-freezing-specific-fields-should-be-possible/59968/2
436    def __post_init__(self):
437        """
438        Initializes the object and freezes fields marked with `"frozen": True`
439        in their metadata.
440        """
441        self.__set_fields_frozen(self)
442
443    @classmethod
444    def __set_fields_frozen(cls, self):
445        """
446        Iterates through the dataclass fields and freezes those with the
447        `"frozen": True` metadata.
448        """
449        flds = dataclasses.fields(cls)
450        for fld in flds:
451            if fld.metadata.get("frozen"):
452                field_name = fld.name
453                field_value = getattr(self, fld.name)
454                setattr(self, f"_{fld.name}", field_value)
455
456                def local_getter(self):
457                    """Getter for the frozen field."""
458                    return getattr(self, f"_{field_name}")
459
460                def frozen(name):
461                    """Creates a setter that raises a RuntimeError for frozen fields."""
462                    def local_setter(self, value):
463                        raise RuntimeError(f"Field '{name}' is frozen!")
464                    return local_setter
465
466                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:
913@dataclasses.dataclass
914class Backup:
915    """
916    Represents a backup of a file.
917
918    Attributes:
919    - path (str): The path to the back-up file.
920    - hash (str): The hash (SHA1) of the backed-up data for integrity verification.
921    """
922    path: str
923    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
class Collection:
1338class Collection:
1339    @staticmethod
1340    def paginate(items: list | dict, page_size: int, page_number: int, debug: bool = False) -> tuple[list[tuple], int, int]:
1341        """
1342        Paginate a list or dictionary of items into pages.
1343
1344        This function divides the input `items` into smaller, equally sized pages.
1345        It handles both lists and dictionaries as input, and ensures the requested
1346        page number is within a valid range.  For lists, it adds the item number
1347        to each item in the result
1348
1349        Parameters:
1350        - items (list | dict): The list or dictionary of items to paginate.
1351            If a dictionary is provided, the keys are used to determine the order of items.
1352        - page_size (int): The maximum number of items to include in each page.
1353            Must be a positive integer.
1354        - page_number (int): The desired page number (1-based index). 
1355            The function will adjust this value to be within the valid range of pages.
1356        - debug (bool, optional): If True, the function will print debug information.
1357            Default is False.
1358
1359        Returns:
1360        - tuple[list[tuple], int, int]: A tuple containing three elements:
1361            -  A list of items for the requested page. If the input `items`
1362                is a list, each item in the output list is a tuple where the
1363                first element is the item number (1-based index within the
1364                original list), and the subsequent elements are the original
1365                item's data. If `items` is a dictionary, the output list
1366                contains the keys of the dictionary for the selected page.
1367            -  The total number of pages (int).
1368            -  The total number of items (int) in the original `items`.
1369        """
1370        total_items = len(items)
1371        total_pages = (total_items + page_size - 1) // page_size  # Calculate total pages
1372        page_number = max(1, min(page_number, total_pages))  # Clamp page number to valid range
1373
1374        start_index = (page_number - 1) * page_size
1375        end_index = start_index + page_size
1376        page_items = []
1377        if type(items) is dict:
1378            page_items = list(items)[start_index:end_index]
1379        else:
1380            for i, item in enumerate(items[start_index:end_index]):
1381                item_number = start_index + i + 1  # Calculate item number
1382                if debug:
1383                    print(item_number, item)
1384                if type(item) is not tuple:
1385                    item = (item,)
1386                page_items.append((item_number,) + item)  # Include item number with item
1387
1388        return page_items, total_pages, total_items
1389
1390    @staticmethod
1391    def test_paginate(debug: bool = False):
1392        """
1393        Test the paginate function with various inputs.
1394
1395        Parameters:
1396        - debug (bool, optional): If True, the function will print debug information. Default is False.
1397        """
1398        if debug:
1399            print("Running tests for Collection.paginate()")
1400
1401        # Test case 1: List of integers
1402        items1 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
1403        page_size1 = 3
1404        page_number1 = 2
1405        expected_output1 = ([(4, 4,),(5, 5,), (6, 6,)], 4, 10)
1406        result1 = Collection.paginate(items1, page_size1, page_number1, debug=debug)
1407        if debug:
1408            print('items:', items1)
1409            print('result:', result1)
1410            print('expected:', expected_output1)
1411        assert result1 == expected_output1, f"Test Case 1 Failed: {result1} != {expected_output1}"
1412        if debug:
1413            print("Test Case 1 Passed")
1414
1415        # Test case 2: List of strings
1416        items2 = ["a", "b", "c", "d", "e"]
1417        page_size2 = 2
1418        page_number2 = 3
1419        expected_output2 = ([(5, 'e',)], 3, 5)
1420        result2 = Collection.paginate(items2, page_size2, page_number2)
1421        if debug:
1422            print('items:', items2)
1423            print('result:', result2)
1424            print('expected:', expected_output2)
1425        assert result2 == expected_output2, f"Test Case 2 Failed: {result2} != {expected_output2}"
1426        if debug:
1427            print("Test Case 2 Passed")
1428
1429        # Test case 3: Dictionary
1430        items3 = {"a": 1, "b": 2, "c": 3, "d": 4}
1431        page_size3 = 2
1432        page_number3 = 1
1433        expected_output3 = (["a", "b"], 2, 4)
1434        result3 = Collection.paginate(items3, page_size3, page_number3)
1435        if debug:
1436            print('items:', items3)
1437            print('result:', result3)
1438            print('expected:', expected_output3)
1439        assert result3 == expected_output3, f"Test Case 3 Failed: {result3} != {expected_output3}"
1440        if debug:
1441            print("Test Case 3 Passed")
1442
1443        # Test case 4: Empty list
1444        items4 = []
1445        page_size4 = 2
1446        page_number4 = 1
1447        expected_output4 = ([], 0, 0)
1448        result4 = Collection.paginate(items4, page_size4, page_number4)
1449        if debug:
1450            print('items:', items4)
1451            print('result:', result4)
1452            print('expected:', expected_output4)
1453        assert result4 == expected_output4, f"Test Case 4 Failed: {result4} != {expected_output4}"
1454        if debug:
1455            print("Test Case 4 Passed")
1456
1457        # Test case 5: page_number out of range
1458        items5 = [1, 2, 3, 4, 5]
1459        page_size5 = 2
1460        page_number5 = 100
1461        expected_output5 = ([(5, 5,)], 3, 5)
1462        result5 = Collection.paginate(items5, page_size5, page_number5)
1463        if debug:
1464            print('items:', items5)
1465            print('result:', result5)
1466            print('expected:', expected_output5)
1467        assert result5 == expected_output5, f"Test Case 5 Failed: {result5} != {expected_output5}"
1468        if debug:
1469            print("Test Case 5 Passed")
1470            print("All test cases passed!")
@staticmethod
def paginate( items: list | dict, page_size: int, page_number: int, debug: bool = False) -> tuple[list[tuple], int, int]:
1339    @staticmethod
1340    def paginate(items: list | dict, page_size: int, page_number: int, debug: bool = False) -> tuple[list[tuple], int, int]:
1341        """
1342        Paginate a list or dictionary of items into pages.
1343
1344        This function divides the input `items` into smaller, equally sized pages.
1345        It handles both lists and dictionaries as input, and ensures the requested
1346        page number is within a valid range.  For lists, it adds the item number
1347        to each item in the result
1348
1349        Parameters:
1350        - items (list | dict): The list or dictionary of items to paginate.
1351            If a dictionary is provided, the keys are used to determine the order of items.
1352        - page_size (int): The maximum number of items to include in each page.
1353            Must be a positive integer.
1354        - page_number (int): The desired page number (1-based index). 
1355            The function will adjust this value to be within the valid range of pages.
1356        - debug (bool, optional): If True, the function will print debug information.
1357            Default is False.
1358
1359        Returns:
1360        - tuple[list[tuple], int, int]: A tuple containing three elements:
1361            -  A list of items for the requested page. If the input `items`
1362                is a list, each item in the output list is a tuple where the
1363                first element is the item number (1-based index within the
1364                original list), and the subsequent elements are the original
1365                item's data. If `items` is a dictionary, the output list
1366                contains the keys of the dictionary for the selected page.
1367            -  The total number of pages (int).
1368            -  The total number of items (int) in the original `items`.
1369        """
1370        total_items = len(items)
1371        total_pages = (total_items + page_size - 1) // page_size  # Calculate total pages
1372        page_number = max(1, min(page_number, total_pages))  # Clamp page number to valid range
1373
1374        start_index = (page_number - 1) * page_size
1375        end_index = start_index + page_size
1376        page_items = []
1377        if type(items) is dict:
1378            page_items = list(items)[start_index:end_index]
1379        else:
1380            for i, item in enumerate(items[start_index:end_index]):
1381                item_number = start_index + i + 1  # Calculate item number
1382                if debug:
1383                    print(item_number, item)
1384                if type(item) is not tuple:
1385                    item = (item,)
1386                page_items.append((item_number,) + item)  # Include item number with item
1387
1388        return page_items, total_pages, total_items

Paginate a list or dictionary of items into pages.

This function divides the input items into smaller, equally sized pages. It handles both lists and dictionaries as input, and ensures the requested page number is within a valid range. For lists, it adds the item number to each item in the result

Parameters:

  • items (list | dict): The list or dictionary of items to paginate. If a dictionary is provided, the keys are used to determine the order of items.
  • page_size (int): The maximum number of items to include in each page. Must be a positive integer.
  • page_number (int): The desired page number (1-based index). The function will adjust this value to be within the valid range of pages.
  • debug (bool, optional): If True, the function will print debug information. Default is False.

Returns:

  • tuple[list[tuple], int, int]: A tuple containing three elements:
    • A list of items for the requested page. If the input items is a list, each item in the output list is a tuple where the first element is the item number (1-based index within the original list), and the subsequent elements are the original item's data. If items is a dictionary, the output list contains the keys of the dictionary for the selected page.
    • The total number of pages (int).
    • The total number of items (int) in the original items.
@staticmethod
def test_paginate(debug: bool = False):
1390    @staticmethod
1391    def test_paginate(debug: bool = False):
1392        """
1393        Test the paginate function with various inputs.
1394
1395        Parameters:
1396        - debug (bool, optional): If True, the function will print debug information. Default is False.
1397        """
1398        if debug:
1399            print("Running tests for Collection.paginate()")
1400
1401        # Test case 1: List of integers
1402        items1 = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
1403        page_size1 = 3
1404        page_number1 = 2
1405        expected_output1 = ([(4, 4,),(5, 5,), (6, 6,)], 4, 10)
1406        result1 = Collection.paginate(items1, page_size1, page_number1, debug=debug)
1407        if debug:
1408            print('items:', items1)
1409            print('result:', result1)
1410            print('expected:', expected_output1)
1411        assert result1 == expected_output1, f"Test Case 1 Failed: {result1} != {expected_output1}"
1412        if debug:
1413            print("Test Case 1 Passed")
1414
1415        # Test case 2: List of strings
1416        items2 = ["a", "b", "c", "d", "e"]
1417        page_size2 = 2
1418        page_number2 = 3
1419        expected_output2 = ([(5, 'e',)], 3, 5)
1420        result2 = Collection.paginate(items2, page_size2, page_number2)
1421        if debug:
1422            print('items:', items2)
1423            print('result:', result2)
1424            print('expected:', expected_output2)
1425        assert result2 == expected_output2, f"Test Case 2 Failed: {result2} != {expected_output2}"
1426        if debug:
1427            print("Test Case 2 Passed")
1428
1429        # Test case 3: Dictionary
1430        items3 = {"a": 1, "b": 2, "c": 3, "d": 4}
1431        page_size3 = 2
1432        page_number3 = 1
1433        expected_output3 = (["a", "b"], 2, 4)
1434        result3 = Collection.paginate(items3, page_size3, page_number3)
1435        if debug:
1436            print('items:', items3)
1437            print('result:', result3)
1438            print('expected:', expected_output3)
1439        assert result3 == expected_output3, f"Test Case 3 Failed: {result3} != {expected_output3}"
1440        if debug:
1441            print("Test Case 3 Passed")
1442
1443        # Test case 4: Empty list
1444        items4 = []
1445        page_size4 = 2
1446        page_number4 = 1
1447        expected_output4 = ([], 0, 0)
1448        result4 = Collection.paginate(items4, page_size4, page_number4)
1449        if debug:
1450            print('items:', items4)
1451            print('result:', result4)
1452            print('expected:', expected_output4)
1453        assert result4 == expected_output4, f"Test Case 4 Failed: {result4} != {expected_output4}"
1454        if debug:
1455            print("Test Case 4 Passed")
1456
1457        # Test case 5: page_number out of range
1458        items5 = [1, 2, 3, 4, 5]
1459        page_size5 = 2
1460        page_number5 = 100
1461        expected_output5 = ([(5, 5,)], 3, 5)
1462        result5 = Collection.paginate(items5, page_size5, page_number5)
1463        if debug:
1464            print('items:', items5)
1465            print('result:', result5)
1466            print('expected:', expected_output5)
1467        assert result5 == expected_output5, f"Test Case 5 Failed: {result5} != {expected_output5}"
1468        if debug:
1469            print("Test Case 5 Passed")
1470            print("All test cases passed!")

Test the paginate function with various inputs.

Parameters:

  • debug (bool, optional): If True, the function will print debug information. Default is False.