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    Timestamp,
 33    Box,
 34    Log,
 35    Account,
 36    Exchange,
 37    History,
 38    Vault,
 39    AccountPaymentPart,
 40    PaymentParts,
 41    SubtractAge,
 42    SubtractAges,
 43    SubtractReport,
 44    TransferTime,
 45    TransferTimes,
 46    TransferRecord,
 47    TransferReport,
 48    BoxPlan,
 49    ZakatPlan,
 50    ZakatSummary,
 51    ZakatReport,
 52    test,
 53    Action,
 54    JSONEncoder,
 55    JSONDecoder,
 56    MathOperation,
 57    WeekDay,
 58    StrictDataclass,
 59    ImmutableWithSelectiveFreeze,
 60)
 61
 62from zakat.file_server import (
 63    start_file_server,
 64    find_available_port,
 65    FileType,
 66)
 67
 68# Shortcuts
 69time = Time.time
 70time_to_datetime = Time.time_to_datetime
 71tracker = ZakatTracker
 72
 73# Version information for the module
 74__version__ = ZakatTracker.Version()
 75__all__ = [
 76    "Time",
 77    "time",
 78    "time_to_datetime",
 79    "tracker",
 80    "SizeInfo",
 81    "FileInfo",
 82    "FileStats",
 83    "TimeSummary",
 84    "Transaction",
 85    "DailyRecords",
 86    "Timeline",
 87    "ImportStatistics",
 88    "CSVRecord",
 89    "ImportReport",
 90    "ZakatTracker",
 91    "AccountID",
 92    "Timestamp",
 93    "Box",
 94    "Log",
 95    "Account",
 96    "Exchange",
 97    "History",
 98    "Vault",
 99    "AccountPaymentPart",
100    "PaymentParts",
101    "SubtractAge",
102    "SubtractAges",
103    "SubtractReport",
104    "TransferTime",
105    "TransferTimes",
106    "TransferRecord",
107    "TransferReport",
108    "BoxPlan",
109    "ZakatPlan",
110    "ZakatSummary",
111    "ZakatReport",
112    "test",
113    "Action",
114    "JSONEncoder",
115    "JSONDecoder",
116    "MathOperation",
117    "WeekDay",
118    "start_file_server",
119    "find_available_port",
120    "FileType",
121    "StrictDataclass",
122    "ImmutableWithSelectiveFreeze",
123]
class Time:
1021class Time:
1022    """
1023    Utility class for generating and manipulating nanosecond-precision timestamps.
1024
1025    This class provides static methods for converting between datetime objects and
1026    nanosecond-precision timestamps, ensuring uniqueness and monotonicity.
1027    """
1028    __last_time_ns = None
1029    __time_diff_ns = None
1030
1031    @staticmethod
1032    def minimum_time_diff_ns() -> tuple[int, int]:
1033        """
1034        Calculates the minimum time difference between two consecutive calls to
1035        `Time._time()` in nanoseconds.
1036
1037        This method is used internally to determine the minimum granularity of
1038        time measurements within the system.
1039
1040        Returns:
1041        - tuple[int, int]:
1042            - The minimum time difference in nanoseconds.
1043            - The number of iterations required to measure the difference.
1044        """
1045        i = 0
1046        x = y = Time._time()
1047        while x == y:
1048            y = Time._time()
1049            i += 1
1050        return y - x, i
1051
1052    @staticmethod
1053    def _time(now: Optional[datetime.datetime] = None) -> Timestamp:
1054        """
1055        Internal method to generate a nanosecond-precision timestamp from a datetime object.
1056
1057        Parameters:
1058        - now (datetime.datetime, optional): The datetime object to generate the timestamp from.
1059        If not provided, the current datetime is used.
1060
1061        Returns:
1062        - int: The timestamp in nanoseconds since the epoch (January 1, 1AD).
1063        """
1064        if now is None:
1065            now = datetime.datetime.now()
1066        ns_in_day = (now - now.replace(
1067            hour=0,
1068            minute=0,
1069            second=0,
1070            microsecond=0,
1071        )).total_seconds() * 10 ** 9
1072        return Timestamp(int(now.toordinal() * 86_400_000_000_000 + ns_in_day))
1073
1074    @staticmethod
1075    def time(now: Optional[datetime.datetime] = None) -> Timestamp:
1076        """
1077        Generates a unique, monotonically increasing timestamp based on the provided
1078        datetime object or the current datetime.
1079
1080        This method ensures that timestamps are unique even if called in rapid succession
1081        by introducing a small delay if necessary, based on the system's minimum
1082        time resolution.
1083
1084        Parameters:
1085        - now (datetime.datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used.
1086
1087        Returns:
1088        - Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD).
1089        """
1090        new_time = Time._time(now)
1091        if Time.__last_time_ns is None:
1092            Time.__last_time_ns = new_time
1093            return new_time
1094        while new_time == Time.__last_time_ns:
1095            if Time.__time_diff_ns is None:
1096                diff, _ = Time.minimum_time_diff_ns()
1097                Time.__time_diff_ns = math.ceil(diff)
1098            time.sleep(Time.__time_diff_ns / 1_000_000_000)
1099            new_time = Time._time()
1100        Time.__last_time_ns = new_time
1101        return new_time
1102
1103    @staticmethod
1104    def time_to_datetime(ordinal_ns: Timestamp) -> datetime.datetime:
1105        """
1106        Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD)
1107        back to a datetime object.
1108
1109        Parameters:
1110        - ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD).
1111
1112        Returns:
1113        - datetime.datetime: The corresponding datetime object.
1114        """
1115        d = datetime.datetime.fromordinal(ordinal_ns // 86_400_000_000_000)
1116        t = datetime.timedelta(seconds=(ordinal_ns % 86_400_000_000_000) // 10 ** 9)
1117        return datetime.datetime.combine(d, datetime.time()) + t
1118
1119    @staticmethod
1120    def duration_from_nanoseconds(ns: int,
1121                                  show_zeros_in_spoken_time: bool = False,
1122                                  spoken_time_separator=',',
1123                                  millennia: str = 'Millennia',
1124                                  century: str = 'Century',
1125                                  years: str = 'Years',
1126                                  days: str = 'Days',
1127                                  hours: str = 'Hours',
1128                                  minutes: str = 'Minutes',
1129                                  seconds: str = 'Seconds',
1130                                  milli_seconds: str = 'MilliSeconds',
1131                                  micro_seconds: str = 'MicroSeconds',
1132                                  nano_seconds: str = 'NanoSeconds',
1133                                  ) -> tuple:
1134        """
1135        REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
1136        Convert NanoSeconds to Human Readable Time Format.
1137        A NanoSeconds is a unit of time in the International System of Units (SI) equal
1138        to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
1139        Its symbol is μs, sometimes simplified to us when Unicode is not available.
1140        A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
1141
1142        INPUT : ms (AKA: MilliSeconds)
1143        OUTPUT: tuple(string time_lapsed, string spoken_time) like format.
1144        OUTPUT Variables: time_lapsed, spoken_time
1145
1146        Example  Input: duration_from_nanoseconds(ns)
1147        **'Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds'**
1148        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')
1149        duration_from_nanoseconds(1234567890123456789012)
1150        """
1151        us, ns = divmod(ns, 1000)
1152        ms, us = divmod(us, 1000)
1153        s, ms = divmod(ms, 1000)
1154        m, s = divmod(s, 60)
1155        h, m = divmod(m, 60)
1156        d, h = divmod(h, 24)
1157        y, d = divmod(d, 365)
1158        c, y = divmod(y, 100)
1159        n, c = divmod(c, 10)
1160        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}'
1161        spoken_time_part = []
1162        if n > 0 or show_zeros_in_spoken_time:
1163            spoken_time_part.append(f'{n: 3d} {millennia}')
1164        if c > 0 or show_zeros_in_spoken_time:
1165            spoken_time_part.append(f'{c: 4d} {century}')
1166        if y > 0 or show_zeros_in_spoken_time:
1167            spoken_time_part.append(f'{y: 3d} {years}')
1168        if d > 0 or show_zeros_in_spoken_time:
1169            spoken_time_part.append(f'{d: 4d} {days}')
1170        if h > 0 or show_zeros_in_spoken_time:
1171            spoken_time_part.append(f'{h: 2d} {hours}')
1172        if m > 0 or show_zeros_in_spoken_time:
1173            spoken_time_part.append(f'{m: 2d} {minutes}')
1174        if s > 0 or show_zeros_in_spoken_time:
1175            spoken_time_part.append(f'{s: 2d} {seconds}')
1176        if ms > 0 or show_zeros_in_spoken_time:
1177            spoken_time_part.append(f'{ms: 3d} {milli_seconds}')
1178        if us > 0 or show_zeros_in_spoken_time:
1179            spoken_time_part.append(f'{us: 3d} {micro_seconds}')
1180        if ns > 0 or show_zeros_in_spoken_time:
1181            spoken_time_part.append(f'{ns: 3d} {nano_seconds}')
1182        return time_lapsed, spoken_time_separator.join(spoken_time_part)
1183
1184    @staticmethod
1185    def test(debug: bool = False):
1186        """
1187        Performs unit tests to verify the correctness of the `Time` class methods.
1188
1189        This method checks the conversion between datetime objects and timestamps,
1190        ensuring accuracy and consistency across various date ranges.
1191
1192        Parameters:
1193        - debug (bool, optional): If True, prints the timestamp and converted datetime for each test case. Defaults to False.
1194        """
1195        test_cases = [
1196            datetime.datetime(1, 1, 1),
1197            datetime.datetime(1970, 1, 1),
1198            datetime.datetime(1969, 12, 31),
1199            datetime.datetime.now(),
1200            datetime.datetime(9999, 12, 31, 23, 59, 59),
1201        ]
1202
1203        for test_date in test_cases:
1204            timestamp = Time.time(test_date)
1205            converted = Time.time_to_datetime(timestamp)
1206            if debug:
1207                print(f'{timestamp} <=> {converted}')
1208            assert timestamp > 0
1209            assert test_date.year == converted.year
1210            assert test_date.month == converted.month
1211            assert test_date.day == converted.day
1212            assert test_date.hour == converted.hour
1213            assert test_date.minute == converted.minute
1214            assert test_date.second in [converted.second - 1, converted.second, converted.second + 1]
1215
1216        # sanity check - convert date since 1AD to 9999AD
1217
1218        for year in range(1, 10_000):
1219            ns = Time.time(datetime.datetime.strptime(f'{year:04d}-12-30 18:30:45.906030', '%Y-%m-%d %H:%M:%S.%f'))
1220            date = Time.time_to_datetime(ns)
1221            if debug:
1222                print(date, date.microsecond)
1223            assert ns > 0
1224            assert date.year == year
1225            assert date.month == 12
1226            assert date.day == 30
1227            assert date.hour == 18
1228            assert date.minute == 30
1229            assert date.second in [44, 45]
1230            #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]:
1031    @staticmethod
1032    def minimum_time_diff_ns() -> tuple[int, int]:
1033        """
1034        Calculates the minimum time difference between two consecutive calls to
1035        `Time._time()` in nanoseconds.
1036
1037        This method is used internally to determine the minimum granularity of
1038        time measurements within the system.
1039
1040        Returns:
1041        - tuple[int, int]:
1042            - The minimum time difference in nanoseconds.
1043            - The number of iterations required to measure the difference.
1044        """
1045        i = 0
1046        x = y = Time._time()
1047        while x == y:
1048            y = Time._time()
1049            i += 1
1050        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:
1074    @staticmethod
1075    def time(now: Optional[datetime.datetime] = None) -> Timestamp:
1076        """
1077        Generates a unique, monotonically increasing timestamp based on the provided
1078        datetime object or the current datetime.
1079
1080        This method ensures that timestamps are unique even if called in rapid succession
1081        by introducing a small delay if necessary, based on the system's minimum
1082        time resolution.
1083
1084        Parameters:
1085        - now (datetime.datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used.
1086
1087        Returns:
1088        - Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD).
1089        """
1090        new_time = Time._time(now)
1091        if Time.__last_time_ns is None:
1092            Time.__last_time_ns = new_time
1093            return new_time
1094        while new_time == Time.__last_time_ns:
1095            if Time.__time_diff_ns is None:
1096                diff, _ = Time.minimum_time_diff_ns()
1097                Time.__time_diff_ns = math.ceil(diff)
1098            time.sleep(Time.__time_diff_ns / 1_000_000_000)
1099            new_time = Time._time()
1100        Time.__last_time_ns = new_time
1101        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:
1103    @staticmethod
1104    def time_to_datetime(ordinal_ns: Timestamp) -> datetime.datetime:
1105        """
1106        Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD)
1107        back to a datetime object.
1108
1109        Parameters:
1110        - ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD).
1111
1112        Returns:
1113        - datetime.datetime: The corresponding datetime object.
1114        """
1115        d = datetime.datetime.fromordinal(ordinal_ns // 86_400_000_000_000)
1116        t = datetime.timedelta(seconds=(ordinal_ns % 86_400_000_000_000) // 10 ** 9)
1117        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:
1119    @staticmethod
1120    def duration_from_nanoseconds(ns: int,
1121                                  show_zeros_in_spoken_time: bool = False,
1122                                  spoken_time_separator=',',
1123                                  millennia: str = 'Millennia',
1124                                  century: str = 'Century',
1125                                  years: str = 'Years',
1126                                  days: str = 'Days',
1127                                  hours: str = 'Hours',
1128                                  minutes: str = 'Minutes',
1129                                  seconds: str = 'Seconds',
1130                                  milli_seconds: str = 'MilliSeconds',
1131                                  micro_seconds: str = 'MicroSeconds',
1132                                  nano_seconds: str = 'NanoSeconds',
1133                                  ) -> tuple:
1134        """
1135        REF https://github.com/JayRizzo/Random_Scripts/blob/master/time_measure.py#L106
1136        Convert NanoSeconds to Human Readable Time Format.
1137        A NanoSeconds is a unit of time in the International System of Units (SI) equal
1138        to one millionth (0.000001 or 10−6 or 1⁄1,000,000) of a second.
1139        Its symbol is μs, sometimes simplified to us when Unicode is not available.
1140        A microsecond is equal to 1000 nanoseconds or 1⁄1,000 of a millisecond.
1141
1142        INPUT : ms (AKA: MilliSeconds)
1143        OUTPUT: tuple(string time_lapsed, string spoken_time) like format.
1144        OUTPUT Variables: time_lapsed, spoken_time
1145
1146        Example  Input: duration_from_nanoseconds(ns)
1147        **'Millennium:Century:Years:Days:Hours:Minutes:Seconds:MilliSeconds:MicroSeconds:NanoSeconds'**
1148        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')
1149        duration_from_nanoseconds(1234567890123456789012)
1150        """
1151        us, ns = divmod(ns, 1000)
1152        ms, us = divmod(us, 1000)
1153        s, ms = divmod(ms, 1000)
1154        m, s = divmod(s, 60)
1155        h, m = divmod(m, 60)
1156        d, h = divmod(h, 24)
1157        y, d = divmod(d, 365)
1158        c, y = divmod(y, 100)
1159        n, c = divmod(c, 10)
1160        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}'
1161        spoken_time_part = []
1162        if n > 0 or show_zeros_in_spoken_time:
1163            spoken_time_part.append(f'{n: 3d} {millennia}')
1164        if c > 0 or show_zeros_in_spoken_time:
1165            spoken_time_part.append(f'{c: 4d} {century}')
1166        if y > 0 or show_zeros_in_spoken_time:
1167            spoken_time_part.append(f'{y: 3d} {years}')
1168        if d > 0 or show_zeros_in_spoken_time:
1169            spoken_time_part.append(f'{d: 4d} {days}')
1170        if h > 0 or show_zeros_in_spoken_time:
1171            spoken_time_part.append(f'{h: 2d} {hours}')
1172        if m > 0 or show_zeros_in_spoken_time:
1173            spoken_time_part.append(f'{m: 2d} {minutes}')
1174        if s > 0 or show_zeros_in_spoken_time:
1175            spoken_time_part.append(f'{s: 2d} {seconds}')
1176        if ms > 0 or show_zeros_in_spoken_time:
1177            spoken_time_part.append(f'{ms: 3d} {milli_seconds}')
1178        if us > 0 or show_zeros_in_spoken_time:
1179            spoken_time_part.append(f'{us: 3d} {micro_seconds}')
1180        if ns > 0 or show_zeros_in_spoken_time:
1181            spoken_time_part.append(f'{ns: 3d} {nano_seconds}')
1182        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):
1184    @staticmethod
1185    def test(debug: bool = False):
1186        """
1187        Performs unit tests to verify the correctness of the `Time` class methods.
1188
1189        This method checks the conversion between datetime objects and timestamps,
1190        ensuring accuracy and consistency across various date ranges.
1191
1192        Parameters:
1193        - debug (bool, optional): If True, prints the timestamp and converted datetime for each test case. Defaults to False.
1194        """
1195        test_cases = [
1196            datetime.datetime(1, 1, 1),
1197            datetime.datetime(1970, 1, 1),
1198            datetime.datetime(1969, 12, 31),
1199            datetime.datetime.now(),
1200            datetime.datetime(9999, 12, 31, 23, 59, 59),
1201        ]
1202
1203        for test_date in test_cases:
1204            timestamp = Time.time(test_date)
1205            converted = Time.time_to_datetime(timestamp)
1206            if debug:
1207                print(f'{timestamp} <=> {converted}')
1208            assert timestamp > 0
1209            assert test_date.year == converted.year
1210            assert test_date.month == converted.month
1211            assert test_date.day == converted.day
1212            assert test_date.hour == converted.hour
1213            assert test_date.minute == converted.minute
1214            assert test_date.second in [converted.second - 1, converted.second, converted.second + 1]
1215
1216        # sanity check - convert date since 1AD to 9999AD
1217
1218        for year in range(1, 10_000):
1219            ns = Time.time(datetime.datetime.strptime(f'{year:04d}-12-30 18:30:45.906030', '%Y-%m-%d %H:%M:%S.%f'))
1220            date = Time.time_to_datetime(ns)
1221            if debug:
1222                print(date, date.microsecond)
1223            assert ns > 0
1224            assert date.year == year
1225            assert date.month == 12
1226            assert date.day == 30
1227            assert date.hour == 18
1228            assert date.minute == 30
1229            assert date.second in [44, 45]
1230            #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:
1074    @staticmethod
1075    def time(now: Optional[datetime.datetime] = None) -> Timestamp:
1076        """
1077        Generates a unique, monotonically increasing timestamp based on the provided
1078        datetime object or the current datetime.
1079
1080        This method ensures that timestamps are unique even if called in rapid succession
1081        by introducing a small delay if necessary, based on the system's minimum
1082        time resolution.
1083
1084        Parameters:
1085        - now (datetime.datetime, optional): The datetime object to generate the timestamp from. If not provided, the current datetime is used.
1086
1087        Returns:
1088        - Timestamp: The unique timestamp in nanoseconds since the epoch (January 1, 1AD).
1089        """
1090        new_time = Time._time(now)
1091        if Time.__last_time_ns is None:
1092            Time.__last_time_ns = new_time
1093            return new_time
1094        while new_time == Time.__last_time_ns:
1095            if Time.__time_diff_ns is None:
1096                diff, _ = Time.minimum_time_diff_ns()
1097                Time.__time_diff_ns = math.ceil(diff)
1098            time.sleep(Time.__time_diff_ns / 1_000_000_000)
1099            new_time = Time._time()
1100        Time.__last_time_ns = new_time
1101        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:
1103    @staticmethod
1104    def time_to_datetime(ordinal_ns: Timestamp) -> datetime.datetime:
1105        """
1106        Converts a nanosecond-precision timestamp (ordinal number of nanoseconds since 1AD)
1107        back to a datetime object.
1108
1109        Parameters:
1110        - ordinal_ns (Timestamp): The timestamp in nanoseconds since the epoch (January 1, 1AD).
1111
1112        Returns:
1113        - datetime.datetime: The corresponding datetime object.
1114        """
1115        d = datetime.datetime.fromordinal(ordinal_ns // 86_400_000_000_000)
1116        t = datetime.timedelta(seconds=(ordinal_ns % 86_400_000_000_000) // 10 ** 9)
1117        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):
762@dataclasses.dataclass
763class SizeInfo(StrictDataclass):
764    """
765    Represents size information in bytes and human-readable format.
766    
767    Attributes:
768    - bytes (float): The size in bytes.
769    - human_readable (str): The human-readable representation of the size.
770    """
771    bytes: float
772    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):
775@dataclasses.dataclass
776class FileInfo(StrictDataclass):
777    """
778    Represents information about a file.
779    
780    Attributes:
781    - type (str): The type of the file.
782    - path (str): The full path to the file.
783    - exists (bool): A boolean indicating whether the file exists.
784    - size (int): The size of the file in bytes.
785    - human_readable_size (str): The human-readable representation of the file size.
786    """
787    type: str
788    path: str
789    exists: bool
790    size: int
791    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):
794@dataclasses.dataclass
795class FileStats(StrictDataclass):
796    """
797    Represents statistics related to file storage.
798    
799    Attributes:
800    - ram (:class:`SizeInfo`): Information about the RAM usage.
801    - database (:class:`SizeInfo`): Information about the database size.
802    """
803    ram: SizeInfo
804    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):
807@dataclasses.dataclass
808class TimeSummary(StrictDataclass):
809    """Summary of positive, negative, and total values over a period."""
810    positive: int = 0
811    negative: int = 0
812    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):
815@dataclasses.dataclass
816class Transaction(StrictDataclass):
817    """Represents a single transaction record."""
818    account: str
819    account_id: AccountID
820    desc: str
821    file: dict[Timestamp, str]
822    value: int
823    time: Timestamp
824    transfer: bool

Represents a single transaction record.

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

Returns a copy of the history of steps taken in the ZakatTracker.

The history is a dictionary where each key is a unique identifier for a step, and the corresponding value is a dictionary containing information about the step.

Parameters: None

Returns:

  • dict: A copy of the history of steps taken in the ZakatTracker.
def free( self, lock: Timestamp, auto_save: bool = True) -> bool:
1683    def free(self, lock: Timestamp, auto_save: bool = True) -> bool:
1684        """
1685        Releases the lock on the database.
1686
1687        Parameters:
1688        - lock (Timestamp): The lock ID to be released.
1689        - auto_save (bool, optional): Whether to automatically save the database after releasing the lock.
1690
1691        Returns:
1692        - bool: True if the lock is successfully released and (optionally) saved, False otherwise.
1693        """
1694        if lock == self.__vault.lock:
1695            self.clean_history(lock)
1696            self.__vault.lock = None
1697            if auto_save and not self.memory_mode():
1698                return self.save(self.path())
1699            return True
1700        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:
1702    def recall(self, dry: bool = True, lock: Optional[Timestamp] = None, debug: bool = False) -> bool:
1703        """
1704        Revert the last operation.
1705
1706        Parameters:
1707        - dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
1708        - lock (Timestamp, optional): An optional lock value to ensure the recall
1709                operation is performed on the expected history entry. If provided,
1710                it checks if the current lock and the most recent history key
1711                match the given lock value. Defaults to None.
1712        - debug (bool, optional): If True, the function will print debug information. Default is False.
1713
1714        Returns:
1715        - bool: True if the operation was successful, False otherwise.
1716        """
1717        if not self.nolock() or len(self.__vault.history) == 0:
1718            return False
1719        if len(self.__vault.history) <= 0:
1720            return False
1721        ref = sorted(self.__vault.history.keys())[-1]
1722        if debug:
1723            print('recall', ref)
1724        memory = sorted(self.__vault.history[ref], reverse=True)
1725        if debug:
1726            print(type(memory), 'memory', memory)
1727        if lock is not None:
1728            assert self.__vault.lock == lock, "Invalid current lock"
1729            assert ref == lock, "Invalid last lock"
1730            assert self.__history(), "History mode should be enabled, found off!!!"
1731        sub_positive_log_negative = 0
1732        for i in memory:
1733            x = self.__vault.history[ref][i]
1734            if debug:
1735                print(type(x), x)
1736            if x.action != Action.REPORT:
1737                assert x.account is not None
1738                if x.action != Action.EXCHANGE:
1739                    assert self.account_exists(x.account)
1740            match x.action:
1741                case Action.CREATE:
1742                    if debug:
1743                        print('account', self.__vault.account[x.account])
1744                    assert len(self.__vault.account[x.account].box) == 0
1745                    assert len(self.__vault.account[x.account].log) == 0
1746                    assert self.__vault.account[x.account].balance == 0
1747                    assert self.__vault.account[x.account].count == 0
1748                    assert self.__vault.account[x.account].name == ''
1749                    if dry:
1750                        continue
1751                    del self.__vault.account[x.account]
1752
1753                case Action.NAME:
1754                    assert x.value is not None
1755                    if dry:
1756                        continue
1757                    self.__vault.account[x.account].name = x.value
1758
1759                case Action.TRACK:
1760                    assert x.value is not None
1761                    assert x.ref is not None
1762                    if dry:
1763                        continue
1764                    self.__vault.account[x.account].balance -= x.value
1765                    self.__vault.account[x.account].count -= 1
1766                    del self.__vault.account[x.account].box[x.ref]
1767
1768                case Action.LOG:
1769                    assert x.ref in self.__vault.account[x.account].log
1770                    assert x.value is not None
1771                    if dry:
1772                        continue
1773                    if sub_positive_log_negative == -x.value:
1774                        self.__vault.account[x.account].count -= 1
1775                        sub_positive_log_negative = 0
1776                    box_ref = self.__vault.account[x.account].log[x.ref].ref
1777                    if not box_ref is None:
1778                        assert self.box_exists(x.account, box_ref)
1779                        box_value = self.__vault.account[x.account].log[x.ref].value
1780                        assert box_value < 0
1781
1782                        try:
1783                            self.__vault.account[x.account].box[box_ref].rest += -box_value
1784                        except TypeError:
1785                            self.__vault.account[x.account].box[box_ref].rest += decimal.Decimal(-box_value)
1786
1787                        try:
1788                            self.__vault.account[x.account].balance += -box_value
1789                        except TypeError:
1790                            self.__vault.account[x.account].balance += decimal.Decimal(-box_value)
1791
1792                        self.__vault.account[x.account].count -= 1
1793                    del self.__vault.account[x.account].log[x.ref]
1794
1795                case Action.SUBTRACT:
1796                    assert x.ref in self.__vault.account[x.account].box
1797                    assert x.value is not None
1798                    if dry:
1799                        continue
1800                    self.__vault.account[x.account].box[x.ref].rest += x.value
1801                    self.__vault.account[x.account].balance += x.value
1802                    sub_positive_log_negative = x.value
1803
1804                case Action.ADD_FILE:
1805                    assert x.ref in self.__vault.account[x.account].log
1806                    assert x.file is not None
1807                    assert dry or x.file in self.__vault.account[x.account].log[x.ref].file
1808                    if dry:
1809                        continue
1810                    del self.__vault.account[x.account].log[x.ref].file[x.file]
1811
1812                case Action.REMOVE_FILE:
1813                    assert x.ref in self.__vault.account[x.account].log
1814                    assert x.file is not None
1815                    assert x.value is not None
1816                    if dry:
1817                        continue
1818                    self.__vault.account[x.account].log[x.ref].file[x.file] = x.value
1819
1820                case Action.BOX_TRANSFER:
1821                    assert x.ref in self.__vault.account[x.account].box
1822                    assert x.value is not None
1823                    if dry:
1824                        continue
1825                    self.__vault.account[x.account].box[x.ref].rest -= x.value
1826
1827                case Action.EXCHANGE:
1828                    assert x.account in self.__vault.exchange
1829                    assert x.ref in self.__vault.exchange[x.account]
1830                    if dry:
1831                        continue
1832                    del self.__vault.exchange[x.account][x.ref]
1833
1834                case Action.REPORT:
1835                    assert x.ref in self.__vault.report
1836                    if dry:
1837                        continue
1838                    del self.__vault.report[x.ref]
1839
1840                case Action.ZAKAT:
1841                    assert x.ref in self.__vault.account[x.account].box
1842                    assert x.key is not None
1843                    assert hasattr(self.__vault.account[x.account].box[x.ref].zakat, x.key)
1844                    if dry:
1845                        continue
1846                    match x.math:
1847                        case MathOperation.ADDITION:
1848                            setattr(
1849                                self.__vault.account[x.account].box[x.ref].zakat,
1850                                x.key,
1851                                getattr(self.__vault.account[x.account].box[x.ref].zakat, x.key) - x.value,
1852                            )
1853                        case MathOperation.EQUAL:
1854                            setattr(
1855                                self.__vault.account[x.account].box[x.ref].zakat,
1856                                x.key,
1857                                x.value,
1858                            )
1859                        case MathOperation.SUBTRACTION:
1860                            setattr(
1861                                self.__vault.account[x.account].box[x.ref],
1862                                x.key,
1863                                getattr(self.__vault.account[x.account].box[x.ref], x.key) + x.value,
1864                            )
1865
1866        if not dry:
1867            del self.__vault.history[ref]
1868        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:
1870    def vault(self) -> dict:
1871        """
1872        Returns a copy of the internal vault dictionary.
1873
1874        This method is used to retrieve the current state of the ZakatTracker object.
1875        It provides a snapshot of the internal data structure, allowing for further
1876        processing or analysis.
1877
1878        Parameters:
1879        None
1880
1881        Returns:
1882        - dict: A copy of the internal vault dictionary.
1883        """
1884        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:
1886    @staticmethod
1887    def stats_init() -> FileStats:
1888        """
1889        Initialize and return the initial file statistics.
1890
1891        Returns:
1892        - FileStats: A :class:`FileStats` instance with initial values
1893            of 0 bytes for both RAM and database.
1894        """
1895        return FileStats(
1896            database=SizeInfo(0, '0'),
1897            ram=SizeInfo(0, '0'),
1898        )

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:
1900    def stats(self, ignore_ram: bool = True) -> FileStats:
1901        """
1902        Calculates and returns statistics about the object's data storage.
1903
1904        This method determines the size of the database file on disk and the
1905        size of the data currently held in RAM (likely within a dictionary).
1906        Both sizes are reported in bytes and in a human-readable format
1907        (e.g., KB, MB).
1908
1909        Parameters:
1910        - ignore_ram (bool, optional): Whether to ignore the RAM size. Default is True
1911
1912        Returns:
1913        - FileStats: A dataclass containing the following statistics:
1914
1915            * 'database': A tuple with two elements:
1916                - The database file size in bytes (float).
1917                - The database file size in human-readable format (str).
1918            * 'ram': A tuple with two elements:
1919                - The RAM usage (dictionary size) in bytes (float).
1920                - The RAM usage in human-readable format (str).
1921
1922        Example:
1923        ```bash
1924        >>> x = ZakatTracker()
1925        >>> stats = x.stats()
1926        >>> print(stats.database)
1927        SizeInfo(bytes=256000, human_readable='250.0 KB')
1928        >>> print(stats.ram)
1929        SizeInfo(bytes=12345, human_readable='12.1 KB')
1930        ```
1931        """
1932        ram_size = 0.0 if ignore_ram else self.get_dict_size(self.vault())
1933        file_size = os.path.getsize(self.path())
1934        return FileStats(
1935            database=SizeInfo(file_size, self.human_readable_size(file_size)),
1936            ram=SizeInfo(ram_size, self.human_readable_size(ram_size)),
1937        )

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]:
1939    def files(self) -> list[FileInfo]:
1940        """
1941        Retrieves information about files associated with this class.
1942
1943        This class method provides a standardized way to gather details about
1944        files used by the class for storage, snapshots, and CSV imports.
1945
1946        Parameters:
1947        None
1948
1949        Returns:
1950        - list[FileInfo]: A list of dataclass, each containing information
1951            about a specific file:
1952
1953            * type (str): The type of file ('database', 'snapshot', 'import_csv').
1954            * path (str): The full file path.
1955            * exists (bool): Whether the file exists on the filesystem.
1956            * size (int): The file size in bytes (0 if the file doesn't exist).
1957            * human_readable_size (str): A human-friendly representation of the file size (e.g., '10 KB', '2.5 MB').
1958        """
1959        result = []
1960        for file_type, path in {
1961            'database': self.path(),
1962            'snapshot': self.snapshot_cache_path(),
1963            'import_csv': self.import_csv_cache_path(),
1964        }.items():
1965            exists = os.path.exists(path)
1966            size = os.path.getsize(path) if exists else 0
1967            human_readable_size = self.human_readable_size(size) if exists else '0'
1968            result.append(FileInfo(
1969                type=file_type,
1970                path=path,
1971                exists=exists,
1972                size=size,
1973                human_readable_size=human_readable_size,
1974            ))
1975        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:
1977    def account_exists(self, account: AccountID) -> bool:
1978        """
1979        Check if the given account exists in the vault.
1980
1981        Parameters:
1982        - account (AccountID): The account reference to check.
1983
1984        Returns:
1985        - bool: True if the account exists, False otherwise.
1986        """
1987        account = AccountID(account)
1988        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:
1990    def box_size(self, account: AccountID) -> int:
1991        """
1992        Calculate the size of the box for a specific account.
1993
1994        Parameters:
1995        - account (AccountID): The account reference for which the box size needs to be calculated.
1996
1997        Returns:
1998        - int: The size of the box for the given account. If the account does not exist, -1 is returned.
1999        """
2000        if self.account_exists(account):
2001            return len(self.__vault.account[account].box)
2002        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:
2004    def log_size(self, account: AccountID) -> int:
2005        """
2006        Get the size of the log for a specific account.
2007
2008        Parameters:
2009        - account (AccountID): The account reference for which the log size needs to be calculated.
2010
2011        Returns:
2012        - int: The size of the log for the given account. If the account does not exist, -1 is returned.
2013        """
2014        if self.account_exists(account):
2015            return len(self.__vault.account[account].log)
2016        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:
2018    @staticmethod
2019    def hash_data(data: bytes, algorithm: str = 'blake2b') -> str:
2020        """
2021        Calculates the hash of given byte data using the specified algorithm.
2022
2023        Parameters:
2024        - data (bytes): The byte data to hash.
2025        - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'.
2026
2027        Returns:
2028        - str: The hexadecimal representation of the data's hash.
2029        """
2030        hash_obj = hashlib.new(algorithm)
2031        hash_obj.update(data)
2032        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:
2034    @staticmethod
2035    def hash_file(file_path: str, algorithm: str = 'blake2b') -> str:
2036        """
2037        Calculates the hash of a file using the specified algorithm.
2038
2039        Parameters:
2040        - file_path (str): The path to the file.
2041        - algorithm (str, optional): The hashing algorithm to use. Defaults to 'blake2b'.
2042
2043        Returns:
2044        - str: The hexadecimal representation of the file's hash.
2045        """
2046        hash_obj = hashlib.new(algorithm)  # Create the hash object
2047        with open(file_path, 'rb') as file:  # Open file in binary mode for reading
2048            for chunk in iter(lambda: file.read(4096), b''):  # Read file in chunks
2049                hash_obj.update(chunk)
2050        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):
2052    def snapshot_cache_path(self):
2053        """
2054        Generate the path for the cache file used to store snapshots.
2055
2056        The cache file is a json file that stores the timestamps of the snapshots.
2057        The file name is derived from the main database file name by replacing the '.json' extension with '.snapshots.json'.
2058
2059        Parameters:
2060        None
2061
2062        Returns:
2063        - str: The path to the cache file.
2064        """
2065        path = str(self.path())
2066        ext = self.ext()
2067        ext_len = len(ext)
2068        if path.endswith(f'.{ext}'):
2069            path = path[:-ext_len - 1]
2070        _, filename = os.path.split(path + f'.snapshots.{ext}')
2071        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:
2073    def snapshot(self) -> bool:
2074        """
2075        This function creates a snapshot of the current database state.
2076
2077        The function calculates the hash of the current database file and checks if a snapshot with the same hash already exists.
2078        If a snapshot with the same hash exists, the function returns True without creating a new snapshot.
2079        If a snapshot with the same hash does not exist, the function creates a new snapshot by saving the current database state
2080        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.
2081
2082        Parameters:
2083        None
2084
2085        Returns:
2086        - bool: True if a snapshot with the same hash already exists or if the snapshot is successfully created. False if the snapshot creation fails.
2087        """
2088        current_hash = self.hash_file(self.path())
2089        cache: dict[str, int] = {}  # hash: time_ns
2090        try:
2091            with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream:
2092                cache = json.load(stream, cls=JSONDecoder)
2093        except:
2094            pass
2095        if current_hash in cache:
2096            return True
2097        ref = time.time_ns()
2098        cache[current_hash] = ref
2099        if not self.save(self.base_path('snapshots', f'{ref}.{self.ext()}')):
2100            return False
2101        with open(self.snapshot_cache_path(), 'w', encoding='utf-8') as stream:
2102            stream.write(json.dumps(cache, cls=JSONEncoder))
2103        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]]:
2105    def snapshots(self, hide_missing: bool = True, verified_hash_only: bool = False) \
2106            -> dict[int, tuple[str, str, bool]]:
2107        """
2108        Retrieve a dictionary of snapshots, with their respective hashes, paths, and existence status.
2109
2110        Parameters:
2111        - hide_missing (bool, optional): If True, only include snapshots that exist in the dictionary. Default is True.
2112        - verified_hash_only (bool, optional): If True, only include snapshots with a valid hash. Default is False.
2113
2114        Returns:
2115        - dict[int, tuple[str, str, bool]]: A dictionary where the keys are the timestamps of the snapshots,
2116        and the values are tuples containing the snapshot's hash, path, and existence status.
2117        """
2118        cache: dict[str, int] = {}  # hash: time_ns
2119        try:
2120            with open(self.snapshot_cache_path(), 'r', encoding='utf-8') as stream:
2121                cache = json.load(stream, cls=JSONDecoder)
2122        except:
2123            pass
2124        if not cache:
2125            return {}
2126        result: dict[int, tuple[str, str, bool]] = {}  # time_ns: (hash, path, exists)
2127        for hash_file, ref in cache.items():
2128            path = self.base_path('snapshots', f'{ref}.{self.ext()}')
2129            exists = os.path.exists(path)
2130            valid_hash = self.hash_file(path) == hash_file if verified_hash_only else True
2131            if (verified_hash_only and not valid_hash) or (verified_hash_only and not exists):
2132                continue
2133            if exists or not hide_missing:
2134                result[ref] = (hash_file, path, exists)
2135        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:
2137    def ref_exists(self, account: AccountID, ref_type: str, ref: Timestamp) -> bool:
2138        """
2139        Check if a specific reference (transaction) exists in the vault for a given account and reference type.
2140
2141        Parameters:
2142        - account (AccountID): The account reference for which to check the existence of the reference.
2143        - ref_type (str): The type of reference (e.g., 'box', 'log', etc.).
2144        - ref (Timestamp): The reference (transaction) number to check for existence.
2145
2146        Returns:
2147        - bool: True if the reference exists for the given account and reference type, False otherwise.
2148        """
2149        account = AccountID(account)
2150        if account in self.__vault.account:
2151            return ref in getattr(self.__vault.account[account], ref_type)
2152        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:
2154    def box_exists(self, account: AccountID, ref: Timestamp) -> bool:
2155        """
2156        Check if a specific box (transaction) exists in the vault for a given account and reference.
2157
2158        Parameters:
2159        - account (AccountID): The account reference for which to check the existence of the box.
2160        - ref (Timestamp): The reference (transaction) number to check for existence.
2161
2162        Returns:
2163        - bool: True if the box exists for the given account and reference, False otherwise.
2164        """
2165        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]:
2167    def track(self, unscaled_value: float | int | decimal.Decimal = 0, desc: str = '', account: AccountID = AccountID('1'),
2168              created_time_ns: Optional[Timestamp] = None,
2169              debug: bool = False) -> Optional[Timestamp]:
2170        """
2171        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.
2172
2173        Parameters:
2174        - unscaled_value (float | int | decimal.Decimal, optional): The value of the transaction. Default is 0.
2175        - desc (str, optional): The description of the transaction. Default is an empty string.
2176        - account (AccountID, optional): The account reference for which the transaction is being tracked. Default is '1'.
2177        - 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.
2178        - debug (bool, optional): Whether to print debug information. Default is False.
2179
2180        Returns:
2181        - Optional[Timestamp]: The timestamp of the transaction in nanoseconds since epoch(1AD).
2182
2183        Raises:
2184        - ValueError: The created_time_ns should be greater than zero.
2185        - ValueError: The log transaction happened again in the same nanosecond time.
2186        - ValueError: The box transaction happened again in the same nanosecond time.
2187        """
2188        return self.__track(
2189            unscaled_value=unscaled_value,
2190            desc=desc,
2191            account=account,
2192            logging=True,
2193            created_time_ns=created_time_ns,
2194            debug=debug,
2195        )

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:
2263    def log_exists(self, account: AccountID, ref: Timestamp) -> bool:
2264        """
2265        Checks if a specific transaction log entry exists for a given account.
2266
2267        Parameters:
2268        - account (AccountID): The account reference associated with the transaction log.
2269        - ref (Timestamp): The reference to the transaction log entry.
2270
2271        Returns:
2272        - bool: True if the transaction log entry exists, False otherwise.
2273        """
2274        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:
2327    def exchange(self, account: AccountID, created_time_ns: Optional[Timestamp] = None,
2328                 rate: Optional[float] = None, description: Optional[str] = None, debug: bool = False) -> Exchange:
2329        """
2330        This method is used to record or retrieve exchange rates for a specific account.
2331
2332        Parameters:
2333        - account (AccountID): The account reference for which the exchange rate is being recorded or retrieved.
2334        - created_time_ns (Timestamp, optional): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
2335        - rate (float, optional): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
2336        - description (str, optional): A description of the exchange rate.
2337        - debug (bool, optional): Whether to print debug information. Default is False.
2338
2339        Returns:
2340        - Exchange: A dictionary containing the latest exchange rate and its description. If no exchange rate is found,
2341        it returns a dictionary with default values for the rate and description.
2342
2343        Raises:
2344        - ValueError: The created should be greater than zero.
2345        """
2346        if debug:
2347            print('exchange', f'debug={debug}')
2348        account = AccountID(account)
2349        if created_time_ns is None:
2350            created_time_ns = Time.time()
2351        if created_time_ns <= 0:
2352            raise ValueError('The created should be greater than zero.')
2353        if rate is not None:
2354            if rate <= 0:
2355                return Exchange()
2356            if account not in self.__vault.exchange:
2357                self.__vault.exchange[account] = {}
2358            if len(self.__vault.exchange[account]) == 0 and rate <= 1:
2359                return Exchange(time=created_time_ns, rate=1)
2360            no_lock = self.nolock()
2361            lock = self.__lock()
2362            self.__vault.exchange[account][created_time_ns] = Exchange(rate=rate, description=description)
2363            self.__step(Action.EXCHANGE, account, ref=created_time_ns, value=rate)
2364            if no_lock:
2365                assert lock is not None
2366                self.free(lock)
2367            if debug:
2368                print('exchange-created-1',
2369                      f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}')
2370
2371        if account in self.__vault.exchange:
2372            valid_rates = [(ts, r) for ts, r in self.__vault.exchange[account].items() if ts <= created_time_ns]
2373            if valid_rates:
2374                latest_rate = max(valid_rates, key=lambda x: x[0])
2375                if debug:
2376                    print('exchange-read-1',
2377                          f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}',
2378                          'latest_rate', latest_rate)
2379                result = latest_rate[1]
2380                result.time = latest_rate[0]
2381                return result  # إرجاع قاموس يحتوي على المعدل والوصف
2382        if debug:
2383            print('exchange-read-0', f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}')
2384        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:
2386    @staticmethod
2387    def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
2388        """
2389        This function calculates the exchanged amount of a currency.
2390
2391        Parameters:
2392        - x (float): The original amount of the currency.
2393        - x_rate (float): The exchange rate of the original currency.
2394        - y_rate (float): The exchange rate of the target currency.
2395
2396        Returns:
2397        - float: The exchanged amount of the target currency.
2398        """
2399        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]]:
2401    def exchanges(self) -> dict[AccountID, dict[Timestamp, Exchange]]:
2402        """
2403        Retrieve the recorded exchange rates for all accounts.
2404
2405        Parameters:
2406        None
2407
2408        Returns:
2409        - dict[AccountID, dict[Timestamp, Exchange]]: A dictionary containing all recorded exchange rates.
2410        The keys are account references or numbers, and the values are dictionaries containing the exchange rates.
2411        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
2412        """
2413        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, int]:
2415    def accounts(self) -> dict[AccountID, int]:
2416        """
2417        Returns a dictionary containing account references as keys and their respective balances as values.
2418
2419        Parameters:
2420        None
2421
2422        Returns:
2423        - dict[AccountID, int]: A dictionary where keys are account references and values are their respective balances.
2424        """
2425        result = {}
2426        for i in self.__vault.account:
2427            result[i] = self.__vault.account[i].balance
2428        return result

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

Parameters: None

Returns:

  • dict[AccountID, int]: A dictionary where keys are account references and values are their respective balances.
def boxes( self, account: AccountID) -> dict[Timestamp, Box]:
2430    def boxes(self, account: AccountID) -> dict[Timestamp, Box]:
2431        """
2432        Retrieve the boxes (transactions) associated with a specific account.
2433
2434        Parameters:
2435        - account (AccountID): The account reference for which to retrieve the boxes.
2436
2437        Returns:
2438        - dict[Timestamp, Box]: A dictionary containing the boxes associated with the given account.
2439        If the account does not exist, an empty dictionary is returned.
2440        """
2441        if self.account_exists(account):
2442            return self.__vault.account[account].box
2443        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]:
2445    def logs(self, account: AccountID) -> dict[Timestamp, Log]:
2446        """
2447        Retrieve the logs (transactions) associated with a specific account.
2448
2449        Parameters:
2450        - account (AccountID): The account reference for which to retrieve the logs.
2451
2452        Returns:
2453        - dict[Timestamp, Log]: A dictionary containing the logs associated with the given account.
2454        If the account does not exist, an empty dictionary is returned.
2455        """
2456        if self.account_exists(account):
2457            return self.__vault.account[account].log
2458        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:
2460    def timeline(self, weekday: WeekDay = WeekDay.FRIDAY, debug: bool = False) -> Timeline:
2461        """
2462        Aggregates transaction logs into a structured timeline.
2463
2464        This method retrieves transaction logs from all accounts and organizes them
2465        into daily, weekly, monthly, and yearly summaries. Each level of the
2466        timeline includes a `TimeSummary` object with the total positive, negative,
2467        and overall values for that period. The daily level also includes a list
2468        of individual `Transaction` records.
2469
2470        Parameters:
2471        - weekday (WeekDay, optional): The day of the week to use as the anchor
2472                for weekly summaries. Defaults to WeekDay.FRIDAY.
2473        - debug (bool, optional): If True, prints intermediate debug information
2474                during processing. Defaults to False.
2475
2476        Returns:
2477        - Timeline: An object containing the aggregated transaction data, organized
2478                into daily, weekly, monthly, and yearly summaries. The 'daily'
2479                attribute is a dictionary where keys are dates (YYYY-MM-DD) and
2480                values are `DailyRecords` objects. The 'weekly' attribute is a
2481                dictionary where keys are the starting datetime of the week and
2482                values are `TimeSummary` objects. The 'monthly' attribute is a
2483                dictionary where keys are year-month strings (YYYY-MM) and values
2484                are `TimeSummary` objects. The 'yearly' attribute is a dictionary
2485                where keys are years (YYYY) and values are `TimeSummary` objects.
2486
2487        Example:
2488        ```bash
2489        >>> from zakat import tracker
2490        >>> ledger = tracker(':memory:')
2491        >>> account1_id = ledger.create_account('account1')
2492        >>> account2_id = ledger.create_account('account2')
2493        >>> ledger.subtract(51, 'desc', account1_id)
2494        >>> ref = ledger.track(100, 'desc', account2_id)
2495        >>> ledger.add_file(account2_id, ref, 'file_0')
2496        >>> ledger.add_file(account2_id, ref, 'file_1')
2497        >>> ledger.add_file(account2_id, ref, 'file_2')
2498        >>> ledger.timeline()
2499        Timeline(
2500            daily={
2501                "2025-04-06": DailyRecords(
2502                    positive=10000,
2503                    negative=5100,
2504                    total=4900,
2505                    rows=[
2506                        Transaction(
2507                            account="account2",
2508                            account_id="63879638114290122752",
2509                            desc="desc2",
2510                            file={
2511                                63879638220705865728: "file_0",
2512                                63879638223391350784: "file_1",
2513                                63879638225766047744: "file_2",
2514                            },
2515                            value=10000,
2516                            time=63879638181936513024,
2517                            transfer=False,
2518                        ),
2519                        Transaction(
2520                            account="account1",
2521                            account_id="63879638104007106560",
2522                            desc="desc",
2523                            file={},
2524                            value=-5100,
2525                            time=63879638149199421440,
2526                            transfer=False,
2527                        ),
2528                    ],
2529                )
2530            },
2531            weekly={
2532                datetime.datetime(2025, 4, 2, 15, 56, 21): TimeSummary(
2533                    positive=10000, negative=0, total=10000
2534                ),
2535                datetime.datetime(2025, 4, 2, 15, 55, 49): TimeSummary(
2536                    positive=0, negative=5100, total=-5100
2537                ),
2538            },
2539            monthly={"2025-04": TimeSummary(positive=10000, negative=5100, total=4900)},
2540            yearly={2025: TimeSummary(positive=10000, negative=5100, total=4900)},
2541        )
2542        ```
2543        """
2544        logs: dict[Timestamp, list[Transaction]] = {}
2545        for account_id in self.accounts():
2546            for log_ref, log in self.logs(account_id).items():
2547                if log_ref not in logs:
2548                    logs[log_ref] = []
2549                logs[log_ref].append(Transaction(
2550                    account=self.name(account_id),
2551                    account_id=account_id,
2552                    desc=log.desc,
2553                    file=log.file,
2554                    value=log.value,
2555                    time=log_ref,
2556                    transfer=False,
2557                ))
2558        if debug:
2559            print('logs', logs)
2560        y = Timeline()
2561        for i in sorted(logs, reverse=True):
2562            dt = Time.time_to_datetime(i)
2563            daily = f'{dt.year}-{dt.month:02d}-{dt.day:02d}'
2564            weekly = dt - datetime.timedelta(days=weekday.value)
2565            monthly = f'{dt.year}-{dt.month:02d}'
2566            yearly = dt.year
2567            # daily
2568            if daily not in y.daily:
2569                y.daily[daily] = DailyRecords()
2570            transfer = len(logs[i]) > 1
2571            if debug:
2572                print('logs[i]', logs[i])
2573            for z in logs[i]:
2574                if debug:
2575                    print('z', z)
2576                # daily
2577                value = z.value
2578                if value > 0:
2579                    y.daily[daily].positive += value
2580                else:
2581                    y.daily[daily].negative += -value
2582                y.daily[daily].total += value
2583                z.transfer = transfer
2584                y.daily[daily].rows.append(z)
2585                # weekly
2586                if weekly not in y.weekly:
2587                    y.weekly[weekly] = TimeSummary()
2588                if value > 0:
2589                    y.weekly[weekly].positive += value
2590                else:
2591                    y.weekly[weekly].negative += -value
2592                y.weekly[weekly].total += value
2593                # monthly
2594                if monthly not in y.monthly:
2595                    y.monthly[monthly] = TimeSummary()
2596                if value > 0:
2597                    y.monthly[monthly].positive += value
2598                else:
2599                    y.monthly[monthly].negative += -value
2600                y.monthly[monthly].total += value
2601                # yearly
2602                if yearly not in y.yearly:
2603                    y.yearly[yearly] = TimeSummary()
2604                if value > 0:
2605                    y.yearly[yearly].positive += value
2606                else:
2607                    y.yearly[yearly].negative += -value
2608                y.yearly[yearly].total += value
2609        if debug:
2610            print('y', y)
2611        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:
2613    def add_file(self, account: AccountID, ref: Timestamp, path: str) -> Timestamp:
2614        """
2615        Adds a file reference to a specific transaction log entry in the vault.
2616
2617        Parameters:
2618        - account (AccountID): The account reference associated with the transaction log.
2619        - ref (Timestamp): The reference to the transaction log entry.
2620        - path (str): The path of the file to be added.
2621
2622        Returns:
2623        - Timestamp: The reference of the added file. If the account or transaction log entry does not exist, returns 0.
2624        """
2625        if self.account_exists(account):
2626            if ref in self.__vault.account[account].log:
2627                no_lock = self.nolock()
2628                lock = self.__lock()
2629                file_ref = Time.time()
2630                self.__vault.account[account].log[ref].file[file_ref] = path
2631                self.__step(Action.ADD_FILE, account, ref=ref, file=file_ref)
2632                if no_lock:
2633                    assert lock is not None
2634                    self.free(lock)
2635                return file_ref
2636        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:
2638    def remove_file(self, account: AccountID, ref: Timestamp, file_ref: Timestamp) -> bool:
2639        """
2640        Removes a file reference from a specific transaction log entry in the vault.
2641
2642        Parameters:
2643        - account (AccountID): The account reference associated with the transaction log.
2644        - ref (Timestamp): The reference to the transaction log entry.
2645        - file_ref (Timestamp): The reference of the file to be removed.
2646
2647        Returns:
2648        - bool: True if the file reference is successfully removed, False otherwise.
2649        """
2650        if self.account_exists(account):
2651            if ref in self.__vault.account[account].log:
2652                if file_ref in self.__vault.account[account].log[ref].file:
2653                    no_lock = self.nolock()
2654                    lock = self.__lock()
2655                    x = self.__vault.account[account].log[ref].file[file_ref]
2656                    del self.__vault.account[account].log[ref].file[file_ref]
2657                    self.__step(Action.REMOVE_FILE, account, ref=ref, file=file_ref, value=x)
2658                    if no_lock:
2659                        assert lock is not None
2660                        self.free(lock)
2661                    return True
2662        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:
2664    def balance(self, account: AccountID = AccountID('1'), cached: bool = True) -> int:
2665        """
2666        Calculate and return the balance of a specific account.
2667
2668        Parameters:
2669        - account (AccountID, optional): The account reference. Default is '1'.
2670        - cached (bool, optional): If True, use the cached balance. If False, calculate the balance from the box. Default is True.
2671
2672        Returns:
2673        - int: The balance of the account.
2674
2675        Notes:
2676        - If cached is True, the function returns the cached balance.
2677        - If cached is False, the function calculates the balance from the box by summing up the 'rest' values of all box items.
2678        """
2679        account = AccountID(account)
2680        if cached:
2681            return self.__vault.account[account].balance
2682        x = 0
2683        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:
2685    def hide(self, account: AccountID, status: Optional[bool] = None) -> bool:
2686        """
2687        Check or set the hide status of a specific account.
2688
2689        Parameters:
2690        - account (AccountID): The account reference.
2691        - status (bool, optional): The new hide status. If not provided, the function will return the current status.
2692
2693        Returns:
2694        - bool: The current or updated hide status of the account.
2695
2696        Raises:
2697        None
2698
2699        Example:
2700        ```bash
2701        >>> tracker = ZakatTracker()
2702        >>> ref = tracker.track(51, 'desc', 'account1')
2703        >>> tracker.hide('account1')  # Set the hide status of 'account1' to True
2704        False
2705        >>> tracker.hide('account1', True)  # Set the hide status of 'account1' to True
2706        True
2707        >>> tracker.hide('account1')  # Get the hide status of 'account1' by default
2708        True
2709        >>> tracker.hide('account1', False)
2710        False
2711        ```
2712        """
2713        if self.account_exists(account):
2714            if status is None:
2715                return self.__vault.account[account].hide
2716            self.__vault.account[account].hide = status
2717            return status
2718        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 create_account(self, name: str) -> AccountID:
2720    def create_account(self, name: str) -> AccountID:
2721        """
2722        Creates a new account with the given name and returns its unique ID.
2723
2724        This method:
2725        1. Checks if an account with the same name (case-insensitive) already exists.
2726        2. Generates a unique `AccountID` based on the current time.
2727        3. Tracks the account creation internally.
2728        4. Sets the account's name.
2729        5. Verifies that the name was set correctly.
2730    
2731        Parameters:
2732        - name: The name of the new account.
2733    
2734        Returns:
2735        - AccountID: The unique `AccountID` of the newly created account.
2736    
2737        Raises:
2738        - AssertionError: If an account with the same name already exists (case-insensitive).
2739        - AssertionError: If the provided name does not match the name set for the account.
2740        """
2741        # check if account not exists
2742        for old_name, _ in self.names(name).items():
2743            assert old_name.lower() != name.lower(), f'account name({name}) already used'
2744        # create new account
2745        account_id = AccountID(Time.time())
2746        self.__track(0, '', account_id)
2747        new_name = self.name(
2748            account=account_id,
2749            new_name=name,
2750        )
2751        assert name == new_name
2752        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: 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]:
2754    def names(self, keyword: str = '') -> dict[str, AccountID]:
2755        """
2756        Retrieves a dictionary of account IDs and names, optionally filtered by a keyword.
2757
2758        Parameters:
2759        - keyword: An optional string to filter account names. If provided, only accounts whose
2760            names contain the keyword (case-insensitive) will be included in the result.
2761            Defaults to an empty string, which returns all accounts.
2762
2763        Returns:
2764        - A dictionary where keys are account names and values are AccountIDs. The dictionary
2765            contains only accounts that match the provided keyword (if any).
2766        """
2767        return {
2768            account.name: account_id
2769            for account_id, account in self.__vault.account.items()
2770            if keyword.lower() in account.name.lower()
2771        }

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:
2773    def name(self, account: AccountID, new_name: Optional[str] = None) -> str:
2774        """
2775        Retrieves or sets the name of an account.
2776
2777        Parameters:
2778        - account: The AccountID of the account.
2779        - new_name: The new name to set for the account. If None, the current name is retrieved.
2780
2781        Returns:
2782        - The current name of the account if `new_name` is None, or the `new_name` if it is set.
2783
2784        Note: Returns an empty string if the account does not exist.
2785        """
2786        if self.account_exists(account):
2787            if new_name is None:
2788                return self.__vault.account[account].name
2789            assert new_name != ''
2790            no_lock = self.nolock()
2791            lock = self.__lock()
2792            self.__step(Action.NAME, account, value=self.__vault.account[account].name)
2793            self.__vault.account[account].name = new_name
2794            if no_lock:
2795                    assert lock is not None
2796                    self.free(lock)
2797            return new_name
2798        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:
2800    def zakatable(self, account: AccountID, status: Optional[bool] = None) -> bool:
2801        """
2802        Check or set the zakatable status of a specific account.
2803
2804        Parameters:
2805        - account (AccountID): The account reference.
2806        - status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
2807
2808        Returns:
2809        - bool: The current or updated zakatable status of the account.
2810
2811        Raises:
2812        None
2813
2814        Example:
2815        ```bash
2816        >>> tracker = ZakatTracker()
2817        >>> ref = tracker.track(51, 'desc', 'account1')
2818        >>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
2819        True
2820        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
2821        True
2822        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
2823        True
2824        >>> tracker.zakatable('account1', False)
2825        False
2826        ```
2827        """
2828        if self.account_exists(account):
2829            if status is None:
2830                return self.__vault.account[account].zakatable
2831            self.__vault.account[account].zakatable = status
2832            return status
2833        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:
2835    def subtract(self, unscaled_value: float | int | decimal.Decimal, desc: str = '', account: AccountID = AccountID('1'),
2836            created_time_ns: Optional[Timestamp] = None,
2837            debug: bool = False) \
2838            -> SubtractReport:
2839        """
2840        Subtracts a specified value from an account's balance, if the amount to subtract is greater than the account's balance,
2841        the remaining amount will be transferred to a new transaction with a negative value.
2842
2843        Parameters:
2844        - unscaled_value (float | int | decimal.Decimal): The amount to be subtracted.
2845        - desc (str, optional): A description for the transaction. Defaults to an empty string.
2846        - account (AccountID, optional): The account reference from which the value will be subtracted. Defaults to '1'.
2847        - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD).
2848                                           If not provided, the current timestamp will be used.
2849        - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False.
2850
2851        Returns:
2852        - SubtractReport: A class containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
2853
2854        Raises:
2855        - ValueError: The unscaled_value should be greater than zero.
2856        - ValueError: The created_time_ns should be greater than zero.
2857        - ValueError: The box transaction happened again in the same nanosecond time.
2858        - ValueError: The log transaction happened again in the same nanosecond time.
2859        """
2860        if debug:
2861            print('sub', f'debug={debug}')
2862        account = AccountID(account)
2863        if unscaled_value <= 0:
2864            raise ValueError('The unscaled_value should be greater than zero.')
2865        if created_time_ns is None:
2866            created_time_ns = Time.time()
2867        if created_time_ns <= 0:
2868            raise ValueError('The created should be greater than zero.')
2869        no_lock = self.nolock()
2870        lock = self.__lock()
2871        self.__track(0, '', account)
2872        value = self.scale(unscaled_value)
2873        self.__log(value=-value, desc=desc, account=account, created_time_ns=created_time_ns, ref=None, debug=debug)
2874        ids = sorted(self.__vault.account[account].box.keys())
2875        limit = len(ids) + 1
2876        target = value
2877        if debug:
2878            print('ids', ids)
2879        ages = SubtractAges()
2880        for i in range(-1, -limit, -1):
2881            if target == 0:
2882                break
2883            j = ids[i]
2884            if debug:
2885                print('i', i, 'j', j)
2886            rest = self.__vault.account[account].box[j].rest
2887            if rest >= target:
2888                self.__vault.account[account].box[j].rest -= target
2889                self.__step(Action.SUBTRACT, account, ref=j, value=target)
2890                ages.append(SubtractAge(box_ref=j, total=target))
2891                target = 0
2892                break
2893            elif target > rest > 0:
2894                chunk = rest
2895                target -= chunk
2896                self.__vault.account[account].box[j].rest = 0
2897                self.__step(Action.SUBTRACT, account, ref=j, value=chunk)
2898                ages.append(SubtractAge(box_ref=j, total=chunk))
2899        if target > 0:
2900            self.__track(
2901                unscaled_value=self.unscale(-target),
2902                desc=desc,
2903                account=account,
2904                logging=False,
2905                created_time_ns=created_time_ns,
2906            )
2907            ages.append(SubtractAge(box_ref=created_time_ns, total=target))
2908        if no_lock:
2909            assert lock is not None
2910            self.free(lock)
2911        return SubtractReport(
2912            log_ref=created_time_ns,
2913            ages=ages,
2914        )

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]:
2916    def transfer(self, unscaled_amount: float | int | decimal.Decimal, from_account: AccountID, to_account: AccountID, desc: str = '',
2917                 created_time_ns: Optional[Timestamp] = None,
2918                 debug: bool = False) -> Optional[TransferReport]:
2919        """
2920        Transfers a specified value from one account to another.
2921
2922        Parameters:
2923        - unscaled_amount (float | int | decimal.Decimal): The amount to be transferred.
2924        - from_account (AccountID): The account reference from which the value will be transferred.
2925        - to_account (AccountID): The account reference to which the value will be transferred.
2926        - desc (str, optional): A description for the transaction. Defaults to an empty string.
2927        - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, the current timestamp will be used.
2928        - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False.
2929
2930        Returns:
2931        - Optional[TransferReport]: A class of timestamps corresponding to the transactions made during the transfer.
2932
2933        Raises:
2934        - ValueError: Transfer to the same account is forbidden.
2935        - ValueError: The created_time_ns should be greater than zero.
2936        - ValueError: The box transaction happened again in the same nanosecond time.
2937        - ValueError: The log transaction happened again in the same nanosecond time.
2938        """
2939        if debug:
2940            print('transfer', f'debug={debug}')
2941        from_account = AccountID(from_account)
2942        to_account = AccountID(to_account)
2943        if from_account == to_account:
2944            raise ValueError(f'Transfer to the same account is forbidden. {to_account}')
2945        if unscaled_amount <= 0:
2946            return None
2947        if created_time_ns is None:
2948            created_time_ns = Time.time()
2949        if created_time_ns <= 0:
2950            raise ValueError('The created should be greater than zero.')
2951        no_lock = self.nolock()
2952        lock = self.__lock()
2953        subtract_report = self.subtract(unscaled_amount, desc, from_account, created_time_ns, debug=debug)
2954        source_exchange = self.exchange(from_account, created_time_ns)
2955        target_exchange = self.exchange(to_account, created_time_ns)
2956
2957        if debug:
2958            print('ages', subtract_report.ages)
2959
2960        transfer_report = TransferReport()
2961        for subtract in subtract_report.ages:
2962            times = TransferTimes()
2963            age = subtract.box_ref
2964            value = subtract.total
2965            assert source_exchange.rate is not None
2966            assert target_exchange.rate is not None
2967            target_amount = int(self.exchange_calc(value, source_exchange.rate, target_exchange.rate))
2968            if debug:
2969                print('target_amount', target_amount)
2970            # Perform the transfer
2971            if self.box_exists(to_account, age):
2972                if debug:
2973                    print('box_exists', age)
2974                capital = self.__vault.account[to_account].box[age].capital
2975                rest = self.__vault.account[to_account].box[age].rest
2976                if debug:
2977                    print(
2978                        f'Transfer(loop) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).')
2979                selected_age = age
2980                if rest + target_amount > capital:
2981                    self.__vault.account[to_account].box[age].capital += target_amount
2982                    selected_age = Time.time()
2983                self.__vault.account[to_account].box[age].rest += target_amount
2984                self.__step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount)
2985                y = self.__log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account,
2986                              created_time_ns=None, ref=None, debug=debug)
2987                times.append(TransferTime(box_ref=age, log_ref=y))
2988                continue
2989            if debug:
2990                print(
2991                    f'Transfer(func) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).')
2992            box_ref = self.__track(
2993                unscaled_value=self.unscale(int(target_amount)),
2994                desc=desc,
2995                account=to_account,
2996                logging=True,
2997                created_time_ns=age,
2998                debug=debug,
2999            )
3000            transfer_report.append(TransferRecord(
3001                box_ref=box_ref,
3002                times=times,
3003            ))
3004        if no_lock:
3005            assert lock is not None
3006            self.free(lock)
3007        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:
3009    def check(self,
3010              silver_gram_price: float,
3011              unscaled_nisab: Optional[float | int | decimal.Decimal] = None,
3012              debug: bool = False,
3013              created_time_ns: Optional[Timestamp] = None,
3014              cycle: Optional[float] = None) -> ZakatReport:
3015        """
3016        Check the eligibility for Zakat based on the given parameters.
3017
3018        Parameters:
3019        - silver_gram_price (float): The price of a gram of silver.
3020        - unscaled_nisab (float | int | decimal.Decimal, optional): The minimum amount of wealth required for Zakat.
3021                        If not provided, it will be calculated based on the silver_gram_price.
3022        - debug (bool, optional): Flag to enable debug mode.
3023        - created_time_ns (Timestamp, optional): The current timestamp. If not provided, it will be calculated using Time.time().
3024        - cycle (float, optional): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
3025
3026        Returns:
3027        - ZakatReport: A tuple containing a boolean indicating the eligibility for Zakat,
3028            a list of brief statistics, and a dictionary containing the Zakat plan.
3029        """
3030        if debug:
3031            print('check', f'debug={debug}')
3032        if created_time_ns is None:
3033            created_time_ns = Time.time()
3034        if cycle is None:
3035            cycle = ZakatTracker.TimeCycle()
3036        if unscaled_nisab is None:
3037            unscaled_nisab = ZakatTracker.Nisab(silver_gram_price)
3038        nisab = self.scale(unscaled_nisab)
3039        plan = ZakatPlan()
3040        summary = ZakatSummary()
3041        below_nisab = 0
3042        valid = False
3043        if debug:
3044            print('exchanges', self.exchanges())
3045        for x in self.__vault.account:
3046            if not self.zakatable(x):
3047                continue
3048            _box = self.__vault.account[x].box
3049            _log = self.__vault.account[x].log
3050            limit = len(_box) + 1
3051            ids = sorted(self.__vault.account[x].box.keys())
3052            for i in range(-1, -limit, -1):
3053                j = ids[i]
3054                rest = float(_box[j].rest)
3055                if rest <= 0:
3056                    continue
3057                exchange = self.exchange(x, created_time_ns=Time.time())
3058                assert exchange.rate is not None
3059                rest = ZakatTracker.exchange_calc(rest, float(exchange.rate), 1)
3060                summary.num_wealth_items += 1
3061                summary.total_wealth += rest
3062                epoch = (created_time_ns - j) / cycle
3063                if debug:
3064                    print(f'Epoch: {epoch}', _box[j])
3065                if _box[j].zakat.last > 0:
3066                    epoch = (created_time_ns - _box[j].zakat.last) / cycle
3067                if debug:
3068                    print(f'Epoch: {epoch}')
3069                epoch = math.floor(epoch)
3070                if debug:
3071                    print(f'Epoch: {epoch}', type(epoch), epoch == 0, 1 - epoch, epoch)
3072                if epoch == 0:
3073                    continue
3074                if debug:
3075                    print('Epoch - PASSED')
3076                summary.num_zakatable_items += 1
3077                summary.total_zakatable_amount += rest
3078                is_nisab = rest >= nisab
3079                total = 0
3080                if is_nisab:
3081                    for _ in range(epoch):
3082                        total += ZakatTracker.ZakatCut(float(rest) - float(total))
3083                    valid = total > 0
3084                elif rest > 0:
3085                    below_nisab += rest
3086                    total = ZakatTracker.ZakatCut(float(rest))
3087                if total > 0:
3088                    if x not in plan:
3089                        plan[x] = []
3090                    summary.total_zakat_due += total
3091                    plan[x].append(BoxPlan(
3092                        below_nisab=not is_nisab,
3093                        total=total,
3094                        count=epoch,
3095                        ref=j,
3096                        box=_box[j],
3097                        log=_log[j],
3098                        exchange=exchange,
3099                    ))
3100        valid = valid or below_nisab >= nisab
3101        if debug:
3102            print(f'below_nisab({below_nisab}) >= nisab({nisab})')
3103        return ZakatReport(
3104            valid=valid,
3105            summary=summary,
3106            plan=plan,
3107        )

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:
3109    def build_payment_parts(self, scaled_demand: int, positive_only: bool = True) -> PaymentParts:
3110        """
3111        Build payment parts for the Zakat distribution.
3112
3113        Parameters:
3114        - scaled_demand (int): The total demand for payment in local currency.
3115        - positive_only (bool, optional): If True, only consider accounts with positive balance. Default is True.
3116
3117        Returns:
3118        - PaymentParts: A dictionary containing the payment parts for each account. The dictionary has the following structure:
3119        {
3120            'account': {
3121                'account_id': {'balance': float, 'rate': float, 'part': float},
3122                ...
3123            },
3124            'exceed': bool,
3125            'demand': int,
3126            'total': float,
3127        }
3128        """
3129        total = 0.0
3130        parts = PaymentParts(
3131            account={},
3132            exceed=False,
3133            demand=int(round(scaled_demand)),
3134            total=0,
3135        )
3136        for x, y in self.accounts().items():
3137            if positive_only and y <= 0:
3138                continue
3139            total += float(y)
3140            exchange = self.exchange(x)
3141            parts.account[x] = AccountPaymentPart(balance=y, rate=exchange.rate, part=0)
3142        parts.total = total
3143        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:
3145    @staticmethod
3146    def check_payment_parts(parts: PaymentParts, debug: bool = False) -> int:
3147        """
3148        Checks the validity of payment parts.
3149
3150        Parameters:
3151        - parts (dict[str, PaymentParts): A dictionary containing payment parts information.
3152        - debug (bool, optional): Flag to enable debug mode.
3153
3154        Returns:
3155        - int: Returns 0 if the payment parts are valid, otherwise returns the error code.
3156
3157        Error Codes:
3158        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
3159        2: 'balance', 'rate' or 'part' key is missing in parts['account'][x].
3160        3: 'part' value in parts['account'][x] is less than 0.
3161        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
3162        5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
3163        6: The sum of 'part' values in parts['account'] does not match with 'demand' value.
3164        """
3165        if debug:
3166            print('check_payment_parts', f'debug={debug}')
3167        # for i in ['demand', 'account', 'total', 'exceed']:
3168        #     if i not in parts:
3169        #         return 1
3170        exceed = parts.exceed
3171        # for j in ['balance', 'rate', 'part']:
3172        #     if j not in parts.account[x]:
3173        #         return 2
3174        for x in parts.account:
3175            if parts.account[x].part < 0:
3176                return 3
3177            if not exceed and parts.account[x].balance <= 0:
3178                return 4
3179        demand = parts.demand
3180        z = 0.0
3181        for _, y in parts.account.items():
3182            if not exceed and y.part > y.balance:
3183                return 5
3184            z += ZakatTracker.exchange_calc(y.part, y.rate, 1.0)
3185        z = round(z, 2)
3186        demand = round(demand, 2)
3187        if debug:
3188            print('check_payment_parts', f'z = {z}, demand = {demand}')
3189            print('check_payment_parts', type(z), type(demand))
3190            print('check_payment_parts', z != demand)
3191            print('check_payment_parts', str(z) != str(demand))
3192        if z != demand and str(z) != str(demand):
3193            return 6
3194        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:
3196    def zakat(self, report: ZakatReport,
3197        parts: Optional[PaymentParts] = None, debug: bool = False) -> bool:
3198        """
3199        Perform Zakat calculation based on the given report and optional parts.
3200
3201        Parameters:
3202        - report (ZakatReport): A dataclass containing the validity of the report, the report data, and the zakat plan.
3203        - parts (PaymentParts, optional): A dictionary containing the payment parts for the zakat.
3204        - debug (bool, optional): A flag indicating whether to print debug information.
3205
3206        Returns:
3207        - bool: True if the zakat calculation is successful, False otherwise.
3208        """
3209        if debug:
3210            print('zakat', f'debug={debug}')
3211        if not report.valid:
3212            return report.valid
3213        parts_exist = parts is not None
3214        if parts_exist:
3215            if self.check_payment_parts(parts, debug=debug) != 0:
3216                return False
3217        if debug:
3218            print('######### zakat #######')
3219            print('parts_exist', parts_exist)
3220        no_lock = self.nolock()
3221        lock = self.__lock()
3222        report_time = Time.time()
3223        self.__vault.report[report_time] = report
3224        self.__step(Action.REPORT, ref=report_time)
3225        created_time_ns = Time.time()
3226        for x in report.plan:
3227            target_exchange = self.exchange(x)
3228            if debug:
3229                print(report.plan[x])
3230                print('-------------')
3231                print(self.__vault.account[x].box)
3232            if debug:
3233                print('plan[x]', report.plan[x])
3234            for plan in report.plan[x]:
3235                j = plan.ref
3236                if debug:
3237                    print('j', j)
3238                assert j
3239                self.__step(Action.ZAKAT, account=x, ref=j, value=self.__vault.account[x].box[j].zakat.last,
3240                           key='last',
3241                           math_operation=MathOperation.EQUAL)
3242                self.__vault.account[x].box[j].zakat.last = created_time_ns
3243                assert target_exchange.rate is not None
3244                amount = ZakatTracker.exchange_calc(float(plan.total), 1, float(target_exchange.rate))
3245                self.__vault.account[x].box[j].zakat.total += amount
3246                self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='total',
3247                           math_operation=MathOperation.ADDITION)
3248                self.__vault.account[x].box[j].zakat.count += plan.count
3249                self.__step(Action.ZAKAT, account=x, ref=j, value=plan.count, key='count',
3250                           math_operation=MathOperation.ADDITION)
3251                if not parts_exist:
3252                    try:
3253                        self.__vault.account[x].box[j].rest -= amount
3254                    except TypeError:
3255                        self.__vault.account[x].box[j].rest -= decimal.Decimal(amount)
3256                    # self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest',
3257                    #            math_operation=MathOperation.SUBTRACTION)
3258                    self.__log(-float(amount), desc='zakat-زكاة', account=x, created_time_ns=None, ref=j, debug=debug)
3259        if parts_exist:
3260            for account, part in parts.account.items():
3261                if part.part == 0:
3262                    continue
3263                if debug:
3264                    print('zakat-part', account, part.rate)
3265                target_exchange = self.exchange(account)
3266                assert target_exchange.rate is not None
3267                amount = ZakatTracker.exchange_calc(part.part, part.rate, target_exchange.rate)
3268                unscaled_amount = self.unscale(int(amount))
3269                if unscaled_amount <= 0:
3270                    if debug:
3271                        print(f"The amount({unscaled_amount:.20f}) it was {amount:.20f} should be greater tha zero.")
3272                    continue
3273                self.subtract(
3274                    unscaled_value=unscaled_amount,
3275                    desc='zakat-part-دفعة-زكاة',
3276                    account=account,
3277                    debug=debug,
3278                )
3279        if no_lock:
3280            assert lock is not None
3281            self.free(lock)
3282        return True

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

Parameters:

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

Returns:

  • bool: True if the zakat calculation is successful, False otherwise.
@staticmethod
def split_at_last_symbol(data: str, symbol: str) -> tuple[str, str]:
3284    @staticmethod
3285    def split_at_last_symbol(data: str, symbol: str) -> tuple[str, str]:
3286        """Splits a string at the last occurrence of a given symbol.
3287    
3288        Parameters:
3289        - data (str): The input string.
3290        - symbol (str): The symbol to split at.
3291    
3292        Returns:
3293        - tuple[str, str]: A tuple containing two strings, the part before the last symbol and
3294            the part after the last symbol. If the symbol is not found, returns (data, "").
3295        """
3296        last_symbol_index = data.rfind(symbol)
3297    
3298        if last_symbol_index != -1:
3299            before_symbol = data[:last_symbol_index]
3300            after_symbol = data[last_symbol_index + len(symbol):]
3301            return before_symbol, after_symbol
3302        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:
3304    def save(self, path: Optional[str] = None, hash_required: bool = True) -> bool:
3305        """
3306        Saves the ZakatTracker's current state to a json file.
3307
3308        This method serializes the internal data (`__vault`).
3309
3310        Parameters:
3311        - path (str, optional): File path for saving. Defaults to a predefined location.
3312        - hash_required (bool, optional): Whether to add the data integrity using a hash. Defaults to True.
3313
3314        Returns:
3315        - bool: True if the save operation is successful, False otherwise.
3316        """
3317        if path is None:
3318            path = self.path()
3319        # first save in tmp file
3320        temp = f'{path}.tmp'
3321        try:
3322            with open(temp, 'w', encoding='utf-8') as stream:
3323                data = json.dumps(self.__vault, cls=JSONEncoder)
3324                stream.write(data)
3325                if hash_required:
3326                    hashed = self.hash_data(data.encode())
3327                    stream.write(f'//{hashed}')
3328            # then move tmp file to original location
3329            shutil.move(temp, path)
3330            return True
3331        except (IOError, OSError) as e:
3332            print(f'Error saving file: {e}')
3333            if os.path.exists(temp):
3334                os.remove(temp)
3335            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:
3337    @staticmethod
3338    def load_vault_from_json(json_string: str) -> Vault:
3339        """Loads a Vault dataclass from a JSON string."""
3340        data = json.loads(json_string)
3341
3342        vault = Vault()
3343
3344        # Load Accounts
3345        for account_reference, account_data in data.get("account", {}).items():
3346            account_reference = AccountID(account_reference)
3347            box_data = account_data.get('box', {})
3348            box = {
3349                Timestamp(ts): Box(
3350                    capital=box_data[str(ts)]["capital"],
3351                    rest=box_data[str(ts)]["rest"],
3352                    zakat=BoxZakat(**box_data[str(ts)]["zakat"]),
3353                )
3354                for ts in box_data
3355            }
3356
3357            log_data = account_data.get('log', {})
3358            log = {Timestamp(ts): Log(
3359                value=log_data[str(ts)]['value'],
3360                desc=log_data[str(ts)]['desc'],
3361                ref=Timestamp(log_data[str(ts)].get('ref')) if log_data[str(ts)].get('ref') is not None else None,
3362                file={Timestamp(ft): fv for ft, fv in log_data[str(ts)].get('file', {}).items()},
3363            ) for ts in log_data}
3364
3365            vault.account[account_reference] = Account(
3366                balance=account_data["balance"],
3367                created=Timestamp(account_data["created"]),
3368                box=box,
3369                count=account_data.get("count", 0),
3370                log=log,
3371                hide=account_data.get("hide", False),
3372                zakatable=account_data.get("zakatable", True),
3373            )
3374
3375        # Load Exchanges
3376        for account_reference, exchange_data in data.get("exchange", {}).items():
3377            account_reference = AccountID(account_reference)
3378            vault.exchange[account_reference] = {}
3379            for timestamp, exchange_details in exchange_data.items():
3380                vault.exchange[account_reference][Timestamp(timestamp)] = Exchange(
3381                    rate=exchange_details.get("rate"),
3382                    description=exchange_details.get("description"),
3383                    time=Timestamp(exchange_details.get("time")) if exchange_details.get("time") is not None else None,
3384                )
3385
3386        # Load History
3387        for timestamp, history_dict in data.get("history", {}).items():
3388            vault.history[Timestamp(timestamp)] = {}
3389            for history_key, history_data in history_dict.items():
3390                vault.history[Timestamp(timestamp)][Timestamp(history_key)] = History(
3391                    action=Action(history_data["action"]),
3392                    account=AccountID(history_data["account"]) if history_data.get("account") is not None else None,
3393                    ref=Timestamp(history_data.get("ref")) if history_data.get("ref") is not None else None,
3394                    file=Timestamp(history_data.get("file")) if history_data.get("file") is not None else None,
3395                    key=history_data.get("key"),
3396                    value=history_data.get("value"),
3397                    math=MathOperation(history_data.get("math")) if history_data.get("math") is not None else None,
3398                )
3399
3400        # Load Lock
3401        vault.lock = Timestamp(data["lock"]) if data.get("lock") is not None else None
3402
3403        # Load Report
3404        for timestamp, report_data in data.get("report", {}).items():
3405            zakat_plan = ZakatPlan()
3406            for account_reference, box_plans in report_data.get("plan", {}).items():
3407                account_reference = AccountID(account_reference)
3408                zakat_plan[account_reference] = []
3409                for box_plan_data in box_plans:
3410                    zakat_plan[account_reference].append(BoxPlan(
3411                        box=Box(
3412                            capital=box_plan_data["box"]["capital"],
3413                            rest=box_plan_data["box"]["rest"],
3414                            zakat=BoxZakat(**box_plan_data["box"]["zakat"]),
3415                        ),
3416                        log=Log(**box_plan_data["log"]),
3417                        exchange=Exchange(**box_plan_data["exchange"]),
3418                        below_nisab=box_plan_data["below_nisab"],
3419                        total=box_plan_data["total"],
3420                        count=box_plan_data["count"],
3421                        ref=Timestamp(box_plan_data["ref"]),
3422                    ))
3423
3424            vault.report[Timestamp(timestamp)] = ZakatReport(
3425                valid=report_data["valid"],
3426                summary=ZakatSummary(**report_data["summary"]),
3427                plan=zakat_plan,
3428            )
3429
3430        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:
3432    def load(self, path: Optional[str] = None, hash_required: bool = True, debug: bool = False) -> bool:
3433        """
3434        Load the current state of the ZakatTracker object from a json file.
3435
3436        Parameters:
3437        - path (str, optional): The path where the json file is located. If not provided, it will use the default path.
3438        - hash_required (bool, optional): Whether to verify the data integrity using a hash. Defaults to True.
3439        - debug (bool, optional): Flag to enable debug mode.
3440
3441        Returns:
3442        - bool: True if the load operation is successful, False otherwise.
3443        """
3444        if path is None:
3445            path = self.path()
3446        try:
3447            if os.path.exists(path):
3448                with open(path, 'r', encoding='utf-8') as stream:
3449                    file = stream.read()
3450                    data, hashed = self.split_at_last_symbol(file, '//')
3451                    if hash_required:
3452                        assert hashed
3453                        if debug:
3454                            print('[debug-load]', hashed)
3455                        new_hash = self.hash_data(data.encode())
3456                        if debug:
3457                            print('[debug-load]', new_hash)
3458                        assert hashed == new_hash, "Hash verification failed. File may be corrupted."
3459                    self.__vault = self.load_vault_from_json(data)
3460                return True
3461            else:
3462                print(f'File not found: {path}')
3463                return False
3464        except (IOError, OSError) as e:
3465            print(f'Error loading file: {e}')
3466            return False

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

Parameters:

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

Returns:

  • bool: True if the load operation is successful, False otherwise.
def import_csv_cache_path(self):
3468    def import_csv_cache_path(self):
3469        """
3470        Generates the cache file path for imported CSV data.
3471
3472        This function constructs the file path where cached data from CSV imports
3473        will be stored. The cache file is a json file (.json extension) appended
3474        to the base path of the object.
3475
3476        Parameters:
3477        None
3478
3479        Returns:
3480        - str: The full path to the import CSV cache file.
3481
3482        Example:
3483        ```bash
3484        >>> obj = ZakatTracker('/data/reports')
3485        >>> obj.import_csv_cache_path()
3486        '/data/reports.import_csv.json'
3487        ```
3488        """
3489        path = str(self.path())
3490        ext = self.ext()
3491        ext_len = len(ext)
3492        if path.endswith(f'.{ext}'):
3493            path = path[:-ext_len - 1]
3494        _, filename = os.path.split(path + f'.import_csv.{ext}')
3495        return self.base_path(filename)

Generates the cache file path for imported CSV data.

This function constructs the file path where cached data from CSV imports will be stored. The cache file is a json file (.json extension) appended to the base path of the object.

Parameters: None

Returns:

  • str: The full path to the import CSV cache file.

Example:

>>> obj = ZakatTracker('/data/reports')
>>> obj.import_csv_cache_path()
'/data/reports.import_csv.json'
@staticmethod
def get_transaction_csv_headers() -> list[str]:
3497    @staticmethod
3498    def get_transaction_csv_headers() -> list[str]:
3499        """
3500        Returns a list of strings representing the headers for a transaction CSV file.
3501
3502        The headers include:
3503        - account: The account associated with the transaction.
3504        - desc: A description of the transaction.
3505        - value: The monetary value of the transaction.
3506        - date: The date of the transaction.
3507        - rate: The applicable rate (if any) for the transaction.
3508        - reference: An optional reference number or identifier for the transaction.
3509
3510        Returns:
3511        - list[str]: A list containing the CSV header strings.
3512        """
3513        return [
3514            "account",
3515            "desc",
3516            "value",
3517            "date",
3518            "rate",
3519            "reference",
3520        ]

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

The headers include:

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

Returns:

  • list[str]: A list containing the CSV header strings.
def import_csv( self, path: str = 'file.csv', scale_decimal_places: int = 0, debug: bool = False) -> ImportReport:
3522    def import_csv(self, path: str = 'file.csv', scale_decimal_places: int = 0, debug: bool = False) -> ImportReport:
3523        """
3524        The function reads the CSV file, checks for duplicate transactions and tries it's best to creates the transactions history accordingly in the system.
3525
3526        Parameters:
3527        - path (str, optional): The path to the CSV file. Default is 'file.csv'.
3528        - scale_decimal_places (int, optional): The number of decimal places to scale the value. Default is 0.
3529        - debug (bool, optional): A flag indicating whether to print debug information.
3530
3531        Returns:
3532        - ImportReport: A dataclass containing the number of transactions created, the number of transactions found in the cache,
3533                and a dictionary of bad transactions.
3534
3535        Notes:
3536        * Currency Pair Assumption: This function assumes that the exchange rates stored for each account
3537                                    are appropriate for the currency pairs involved in the conversions.
3538        * The exchange rate for each account is based on the last encountered transaction rate that is not equal
3539            to 1.0 or the previous rate for that account.
3540        * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent
3541            transactions of the same account within the whole imported and existing dataset when doing `transfer`, `check` and
3542            `zakat` operations.
3543
3544        Example:
3545            The CSV file should have the following format, rate and reference are optionals per transaction:
3546            account, desc, value, date, rate, reference
3547            For example:
3548            safe-45, 'Some text', 34872, 1988-06-30 00:00:00.000000, 1, 6554
3549        """
3550        if debug:
3551            print('import_csv', f'debug={debug}')
3552        cache: list[int] = []
3553        try:
3554            if not self.memory_mode():
3555                with open(self.import_csv_cache_path(), 'r', encoding='utf-8') as stream:
3556                    cache = json.load(stream)
3557        except Exception as e:
3558            if debug:
3559                print(e)
3560        date_formats = [
3561            '%Y-%m-%d %H:%M:%S.%f',
3562            '%Y-%m-%dT%H:%M:%S.%f',
3563            '%Y-%m-%dT%H%M%S.%f',
3564            '%Y-%m-%d',
3565        ]
3566        statistics = ImportStatistics(0, 0, 0)
3567        data: dict[int, list[CSVRecord]] = {}
3568        with open(path, newline='', encoding='utf-8') as f:
3569            i = 0
3570            for row in csv.reader(f, delimiter=','):
3571                if debug:
3572                    print(f"csv_row({i})", row, type(row))
3573                if row == self.get_transaction_csv_headers():
3574                    continue
3575                i += 1
3576                hashed = hash(tuple(row))
3577                if hashed in cache:
3578                    statistics.found += 1
3579                    continue
3580                account = row[0]
3581                desc = row[1]
3582                value = float(row[2])
3583                rate = 1.0
3584                reference = ''
3585                if row[4:5]: # Empty list if index is out of range
3586                    rate = float(row[4])
3587                if row[5:6]:
3588                    reference = row[5]
3589                date: int = 0
3590                for time_format in date_formats:
3591                    try:
3592                        date_str = row[3]
3593                        if "." not in date_str:
3594                            date_str += ".000000"
3595                        date = Time.time(datetime.datetime.strptime(date_str, time_format))
3596                        break
3597                    except Exception as e:
3598                        if debug:
3599                            print(e)
3600                record = CSVRecord(
3601                    index=i,
3602                    account=account,
3603                    desc=desc,
3604                    value=value,
3605                    date=date,
3606                    rate=rate,
3607                    reference=reference,
3608                    hashed=hashed,
3609                    error='',
3610                )
3611                if date <= 0:
3612                    record.error = 'invalid date'
3613                    statistics.bad += 1
3614                if value == 0:
3615                    record.error = 'invalid value'
3616                    statistics.bad += 1
3617                    continue
3618                if date not in data:
3619                    data[date] = []
3620                data[date].append(record)
3621
3622        if debug:
3623            print('import_csv', len(data))
3624
3625        if statistics.bad > 0:
3626            return ImportReport(
3627                statistics=statistics,
3628                bad=[
3629                    item
3630                    for sublist in data.values()
3631                    for item in sublist
3632                    if item.error
3633                ],
3634            )
3635
3636        no_lock = self.nolock()
3637        lock = self.__lock()
3638        names = self.names()
3639
3640        # sync accounts
3641        if debug:
3642            print('before-names', names, len(names))
3643        for date, rows in sorted(data.items()):
3644            new_rows: list[CSVRecord] = []
3645            for row in rows:
3646                if row.account not in names:
3647                    account_id = self.create_account(row.account)
3648                    names[row.account] = account_id
3649                account_id = names[row.account]
3650                assert account_id
3651                row.account = account_id
3652                new_rows.append(row)
3653            assert new_rows
3654            assert date in data
3655            data[date] = new_rows
3656        if debug:
3657            print('after-names', names, len(names))
3658            assert names == self.names()
3659
3660        # do ops
3661        for date, rows in sorted(data.items()):
3662            try:
3663                def process(x: CSVRecord):
3664                    x.value = self.unscale(
3665                        x.value,
3666                        decimal_places=scale_decimal_places,
3667                    ) if scale_decimal_places > 0 else x.value
3668                    if x.rate > 0:
3669                        self.exchange(account=x.account, created_time_ns=x.date, rate=x.rate)
3670                    if x.value > 0:
3671                        self.track(unscaled_value=x.value, desc=x.desc, account=x.account, created_time_ns=x.date)
3672                    elif x.value < 0:
3673                        self.subtract(unscaled_value=-x.value, desc=x.desc, account=x.account, created_time_ns=x.date)
3674                    return x.hashed
3675                len_rows = len(rows)
3676                # If records are found at the same time with different accounts in the same amount
3677                # (one positive and the other negative), this indicates it is a transfer.
3678                if len_rows > 2 or len_rows == 1:
3679                    for row in rows:
3680                        hashed = process(row)
3681                        assert hashed not in cache
3682                        cache.append(hashed)
3683                        statistics.created += 1
3684                    continue
3685                x1 = rows[0]
3686                x2 = rows[1]
3687                if x1.account == x2.account:
3688                    continue
3689                    # raise Exception(f'invalid transfer')
3690                # not transfer - same time - normal ops
3691                if abs(x1.value) != abs(x2.value) and x1.date == x2.date:
3692                    rows[1].date += 1
3693                    for row in rows:
3694                        hashed = process(row)
3695                        assert hashed not in cache
3696                        cache.append(hashed)
3697                        statistics.created += 1
3698                    continue
3699                if x1.rate > 0:
3700                    self.exchange(x1.account, created_time_ns=x1.date, rate=x1.rate)
3701                if x2.rate > 0:
3702                    self.exchange(x2.account, created_time_ns=x2.date, rate=x2.rate)
3703                x1.value = self.unscale(
3704                    x1.value,
3705                    decimal_places=scale_decimal_places,
3706                ) if scale_decimal_places > 0 else x1.value
3707                x2.value = self.unscale(
3708                    x2.value,
3709                    decimal_places=scale_decimal_places,
3710                ) if scale_decimal_places > 0 else x2.value
3711                # just transfer
3712                values = {
3713                    x1.value: x1.account,
3714                    x2.value: x2.account,
3715                }
3716                if debug:
3717                    print('values', values)
3718                if len(values) <= 1:
3719                    continue
3720                self.transfer(
3721                    unscaled_amount=abs(x1.value),
3722                    from_account=values[min(values.keys())],
3723                    to_account=values[max(values.keys())],
3724                    desc=x1.desc,
3725                    created_time_ns=x1.date,
3726                )
3727            except Exception as e:
3728                for row in rows:
3729                    _tuple = tuple()
3730                    for field in row:
3731                        _tuple += (field,)
3732                    _tuple += (e,)
3733                    bad[i] = _tuple
3734                break
3735        if not self.memory_mode():
3736            with open(self.import_csv_cache_path(), 'w', encoding='utf-8') as stream:
3737                stream.write(json.dumps(cache))
3738        if no_lock:
3739            assert lock is not None
3740            self.free(lock)
3741        report = ImportReport(
3742            statistics=statistics,
3743            bad=[
3744                item
3745                for sublist in data.values()
3746                for item in sublist
3747                if item.error
3748            ],
3749        )
3750        if debug:
3751            debug_path = f'{self.import_csv_cache_path()}.debug.json'
3752            with open(debug_path, 'w', encoding='utf-8') as file:
3753                json.dump(report, file, indent=4, cls=JSONEncoder)
3754                print(f'generated debug report @ `{debug_path}`...')
3755        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.
  • 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:
3761    @staticmethod
3762    def human_readable_size(size: float, decimal_places: int = 2) -> str:
3763        """
3764        Converts a size in bytes to a human-readable format (e.g., KB, MB, GB).
3765
3766        This function iterates through progressively larger units of information
3767        (B, KB, MB, GB, etc.) and divides the input size until it fits within a
3768        range that can be expressed with a reasonable number before the unit.
3769
3770        Parameters:
3771        - size (float): The size in bytes to convert.
3772        - decimal_places (int, optional): The number of decimal places to display
3773            in the result. Defaults to 2.
3774
3775        Returns:
3776        - str: A string representation of the size in a human-readable format,
3777            rounded to the specified number of decimal places. For example:
3778                - '1.50 KB' (1536 bytes)
3779                - '23.00 MB' (24117248 bytes)
3780                - '1.23 GB' (1325899906 bytes)
3781        """
3782        if type(size) not in (float, int):
3783            raise TypeError('size must be a float or integer')
3784        if type(decimal_places) != int:
3785            raise TypeError('decimal_places must be an integer')
3786        for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']:
3787            if size < 1024.0:
3788                break
3789            size /= 1024.0
3790        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:
3792    @staticmethod
3793    def get_dict_size(obj: dict, seen: Optional[set] = None) -> float:
3794        """
3795        Recursively calculates the approximate memory size of a dictionary and its contents in bytes.
3796
3797        This function traverses the dictionary structure, accounting for the size of keys, values,
3798        and any nested objects. It handles various data types commonly found in dictionaries
3799        (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case
3800        of circular references.
3801
3802        Parameters:
3803        - obj (dict): The dictionary whose size is to be calculated.
3804        - seen (set, optional): A set used internally to track visited objects
3805                             and avoid circular references. Defaults to None.
3806
3807        Returns:
3808         - float: An approximate size of the dictionary and its contents in bytes.
3809
3810        Notes:
3811        - This function is a method of the `ZakatTracker` class and is likely used to
3812          estimate the memory footprint of data structures relevant to Zakat calculations.
3813        - The size calculation is approximate as it relies on `sys.getsizeof()`, which might
3814          not account for all memory overhead depending on the Python implementation.
3815        - Circular references are handled to prevent infinite recursion.
3816        - Basic numeric types (int, float, complex) are assumed to have fixed sizes.
3817        - String sizes are estimated based on character length and encoding.
3818        """
3819        size = 0
3820        if seen is None:
3821            seen = set()
3822
3823        obj_id = id(obj)
3824        if obj_id in seen:
3825            return 0
3826
3827        seen.add(obj_id)
3828        size += sys.getsizeof(obj)
3829
3830        if isinstance(obj, dict):
3831            for k, v in obj.items():
3832                size += ZakatTracker.get_dict_size(k, seen)
3833                size += ZakatTracker.get_dict_size(v, seen)
3834        elif isinstance(obj, (list, tuple, set, frozenset)):
3835            for item in obj:
3836                size += ZakatTracker.get_dict_size(item, seen)
3837        elif isinstance(obj, (int, float, complex)):  # Handle numbers
3838            pass  # Basic numbers have a fixed size, so nothing to add here
3839        elif isinstance(obj, str):  # Handle strings
3840            size += len(obj) * sys.getsizeof(str().encode())  # Size per character in bytes
3841        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:
3843    @staticmethod
3844    def day_to_time(day: int, month: int = 6, year: int = 2024) -> Timestamp:  # افتراض أن الشهر هو يونيو والسنة 2024
3845        """
3846        Convert a specific day, month, and year into a timestamp.
3847
3848        Parameters:
3849        - day (int): The day of the month.
3850        - month (int, optional): The month of the year. Default is 6 (June).
3851        - year (int, optional): The year. Default is 2024.
3852
3853        Returns:
3854        - Timestamp: The timestamp representing the given day, month, and year.
3855
3856        Note:
3857        - This method assumes the default month and year if not provided.
3858        """
3859        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:
3861    @staticmethod
3862    def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
3863        """
3864        Generate a random date between two given dates.
3865
3866        Parameters:
3867        - start_date (datetime.datetime): The start date from which to generate a random date.
3868        - end_date (datetime.datetime): The end date until which to generate a random date.
3869
3870        Returns:
3871        - datetime.datetime: A random date between the start_date and end_date.
3872        """
3873        time_between_dates = end_date - start_date
3874        days_between_dates = time_between_dates.days
3875        random_number_of_days = random.randrange(days_between_dates)
3876        return start_date + datetime.timedelta(days=random_number_of_days)

Generate a random date between two given dates.

Parameters:

  • start_date (datetime.datetime): The start date from which to generate a random date.
  • end_date (datetime.datetime): The end date until which to generate a random date.

Returns:

  • datetime.datetime: A random date between the start_date and end_date.
@staticmethod
def generate_random_csv_file( path: str = 'data.csv', count: int = 1000, with_rate: bool = False, debug: bool = False) -> int:
3878    @staticmethod
3879    def generate_random_csv_file(path: str = 'data.csv', count: int = 1_000, with_rate: bool = False,
3880                                 debug: bool = False) -> int:
3881        """
3882        Generate a random CSV file with specified parameters.
3883        The function generates a CSV file at the specified path with the given count of rows.
3884        Each row contains a randomly generated account, description, value, and date.
3885        The value is randomly generated between 1000 and 100000,
3886        and the date is randomly generated between 1950-01-01 and 2023-12-31.
3887        If the row number is not divisible by 13, the value is multiplied by -1.
3888
3889        Parameters:
3890        - path (str, optional): The path where the CSV file will be saved. Default is 'data.csv'.
3891        - count (int, optional): The number of rows to generate in the CSV file. Default is 1000.
3892        - with_rate (bool, optional): If True, a random rate between 1.2% and 12% is added. Default is False.
3893        - debug (bool, optional): A flag indicating whether to print debug information.
3894
3895        Returns:
3896        - int: number of generated records.
3897        """
3898        if debug:
3899            print('generate_random_csv_file', f'debug={debug}')
3900        i = 0
3901        with open(path, 'w', newline='', encoding='utf-8') as csvfile:
3902            writer = csv.writer(csvfile)
3903            writer.writerow(ZakatTracker.get_transaction_csv_headers())
3904            for i in range(count):
3905                account = f'acc-{random.randint(1, count)}'
3906                desc = f'Some text {random.randint(1, count)}'
3907                value = random.randint(1000, 100000)
3908                date = ZakatTracker.generate_random_date(
3909                    datetime.datetime(1000, 1, 1),
3910                    datetime.datetime(2023, 12, 31),
3911                ).strftime('%Y-%m-%d %H:%M:%S.%f' if i % 2 == 0 else '%Y-%m-%d %H:%M:%S')
3912                if not i % 13 == 0:
3913                    value *= -1
3914                row = [account, desc, value, date]
3915                if with_rate:
3916                    rate = random.randint(1, 100) * 0.12
3917                    if debug:
3918                        print('before-append', row)
3919                    row.append(rate)
3920                    if debug:
3921                        print('after-append', row)
3922                if i % 2 == 1:
3923                    row += (Time.time(),)
3924                writer.writerow(row)
3925                i = i + 1
3926        return i

Generate a random CSV file with specified parameters. The function generates a CSV file at the specified path with the given count of rows. Each row contains a randomly generated account, description, value, and date. The value is randomly generated between 1000 and 100000, and the date is randomly generated between 1950-01-01 and 2023-12-31. If the row number is not divisible by 13, the value is multiplied by -1.

Parameters:

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

Returns:

  • int: number of generated records.
@staticmethod
def create_random_list(max_sum: int, min_value: int = 0, max_value: int = 10):
3928    @staticmethod
3929    def create_random_list(max_sum: int, min_value: int = 0, max_value: int = 10):
3930        """
3931        Creates a list of random integers whose sum does not exceed the specified maximum.
3932
3933        Parameters:
3934        - max_sum (int): The maximum allowed sum of the list elements.
3935        - min_value (int, optional): The minimum possible value for an element (inclusive).
3936        - max_value (int, optional): The maximum possible value for an element (inclusive).
3937
3938        Returns:
3939        - A list of random integers.
3940        """
3941        result = []
3942        current_sum = 0
3943
3944        while current_sum < max_sum:
3945            # Calculate the remaining space for the next element
3946            remaining_sum = max_sum - current_sum
3947            # Determine the maximum possible value for the next element
3948            next_max_value = min(remaining_sum, max_value)
3949            # Generate a random element within the allowed range
3950            next_element = random.randint(min_value, next_max_value)
3951            result.append(next_element)
3952            current_sum += next_element
3953
3954        return result

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

Parameters:

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

Returns:

  • A list of random integers.
def test(self, debug: bool = False) -> bool:
4289    def test(self, debug: bool = False) -> bool:
4290        if debug:
4291            print('test', f'debug={debug}')
4292        try:
4293
4294            self._test_core(True, debug)
4295            self._test_core(False, debug)
4296
4297            # test_names
4298            self.reset()
4299            x = "test_names"
4300            failed = False
4301            try:
4302                assert self.name(x) == ''
4303            except:
4304                failed = True
4305            assert failed
4306            assert self.names() == {}
4307            failed = False
4308            try:
4309                assert self.name(x, 'qwe') == ''
4310            except:
4311                failed = True
4312            assert failed
4313            account_id0 = self.create_account(x)
4314            assert isinstance(account_id0, AccountID)
4315            assert int(account_id0) > 0
4316            assert self.name(account_id0) == x
4317            assert self.name(account_id0, 'qwe') == 'qwe'
4318            if debug:
4319                print(self.names(keyword='qwe'))
4320            assert self.names(keyword='asd') == {}
4321            assert self.names(keyword='qwe') == {'qwe': account_id0}
4322
4323            # test_create_account
4324            account_name = "test_account"
4325            assert self.names(keyword=account_name) == {}
4326            account_id = self.create_account(account_name)
4327            assert isinstance(account_id, AccountID)
4328            assert int(account_id) > 0
4329            assert account_id in self.__vault.account
4330            assert self.name(account_id) == account_name
4331            assert self.names(keyword=account_name) == {account_name: account_id}
4332
4333            failed = False
4334            try:
4335                self.create_account(account_name)
4336            except:
4337                failed = True
4338            assert failed
4339
4340            # rename account
4341            assert self.name(account_id) == account_name
4342            assert self.name(account_id, 'asd') == 'asd'
4343            assert self.name(account_id) == 'asd'
4344            # use old and not used name
4345            account_id2 = self.create_account(account_name)
4346            assert int(account_id2) > 0
4347            assert account_id != account_id2
4348            assert self.name(account_id2) == account_name
4349            assert self.names(keyword=account_name) == {account_name: account_id2}
4350
4351            assert self.__history()
4352            count = len(self.__vault.history)
4353            if debug:
4354                print('history-count', count)
4355            assert count == 8
4356
4357            assert self.recall(dry=False, debug=debug)
4358            assert self.name(account_id2) == ''
4359            assert self.account_exists(account_id2)
4360            assert self.recall(dry=False, debug=debug)
4361            assert not self.account_exists(account_id2)
4362            assert self.recall(dry=False, debug=debug)
4363            assert self.name(account_id) == account_name
4364            assert self.recall(dry=False, debug=debug)
4365            assert self.account_exists(account_id)
4366            assert self.recall(dry=False, debug=debug)
4367            assert not self.account_exists(account_id)
4368            assert self.names(keyword='qwe') == {'qwe': account_id0}
4369            assert self.recall(dry=False, debug=debug)
4370            assert self.names(keyword='qwe') == {}
4371            assert self.name(account_id0) == x
4372            assert self.recall(dry=False, debug=debug)
4373            assert self.name(account_id0) == ''
4374            assert self.account_exists(account_id0)
4375            assert self.recall(dry=False, debug=debug)
4376            assert not self.account_exists(account_id0)
4377            assert not self.recall(dry=False, debug=debug)
4378
4379            # Not allowed for duplicate transactions in the same account and time
4380
4381            created = Time.time()
4382            same_account_id = self.create_account('same')
4383            self.track(100, 'test-1', same_account_id, True, created)
4384            failed = False
4385            try:
4386                self.track(50, 'test-1', same_account_id, True, created)
4387            except:
4388                failed = True
4389            assert failed is True
4390
4391            self.reset()
4392
4393            # Same account transfer
4394            for x in [1, 'a', True, 1.8, None]:
4395                failed = False
4396                try:
4397                    self.transfer(1, x, x, 'same-account', debug=debug)
4398                except:
4399                    failed = True
4400                assert failed is True
4401
4402            # Always preserve box age during transfer
4403
4404            series: list[tuple[int, int]] = [
4405                (30, 4),
4406                (60, 3),
4407                (90, 2),
4408            ]
4409            case = {
4410                3000: {
4411                    'series': series,
4412                    'rest': 15000,
4413                },
4414                6000: {
4415                    'series': series,
4416                    'rest': 12000,
4417                },
4418                9000: {
4419                    'series': series,
4420                    'rest': 9000,
4421                },
4422                18000: {
4423                    'series': series,
4424                    'rest': 0,
4425                },
4426                27000: {
4427                    'series': series,
4428                    'rest': -9000,
4429                },
4430                36000: {
4431                    'series': series,
4432                    'rest': -18000,
4433                },
4434            }
4435
4436            selected_time = Time.time() - ZakatTracker.TimeCycle()
4437            ages_account_id = self.create_account('ages')
4438            future_account_id = self.create_account('future')
4439
4440            for total in case:
4441                if debug:
4442                    print('--------------------------------------------------------')
4443                    print(f'case[{total}]', case[total])
4444                for x in case[total]['series']:
4445                    self.track(
4446                        unscaled_value=x[0],
4447                        desc=f'test-{x} ages',
4448                        account=ages_account_id,
4449                        created_time_ns=selected_time * x[1],
4450                    )
4451
4452                unscaled_total = self.unscale(total)
4453                if debug:
4454                    print('unscaled_total', unscaled_total)
4455                refs = self.transfer(
4456                    unscaled_amount=unscaled_total,
4457                    from_account=ages_account_id,
4458                    to_account=future_account_id,
4459                    desc='Zakat Movement',
4460                    debug=debug,
4461                )
4462
4463                if debug:
4464                    print('refs', refs)
4465
4466                ages_cache_balance = self.balance(ages_account_id)
4467                ages_fresh_balance = self.balance(ages_account_id, False)
4468                rest = case[total]['rest']
4469                if debug:
4470                    print('source', ages_cache_balance, ages_fresh_balance, rest)
4471                assert ages_cache_balance == rest
4472                assert ages_fresh_balance == rest
4473
4474                future_cache_balance = self.balance(future_account_id)
4475                future_fresh_balance = self.balance(future_account_id, False)
4476                if debug:
4477                    print('target', future_cache_balance, future_fresh_balance, total)
4478                    print('refs', refs)
4479                assert future_cache_balance == total
4480                assert future_fresh_balance == total
4481
4482                # TODO: check boxes times for `ages` should equal box times in `future`
4483                for ref in self.__vault.account[ages_account_id].box:
4484                    ages_capital = self.__vault.account[ages_account_id].box[ref].capital
4485                    ages_rest = self.__vault.account[ages_account_id].box[ref].rest
4486                    future_capital = 0
4487                    if ref in self.__vault.account[future_account_id].box:
4488                        future_capital = self.__vault.account[future_account_id].box[ref].capital
4489                    future_rest = 0
4490                    if ref in self.__vault.account[future_account_id].box:
4491                        future_rest = self.__vault.account[future_account_id].box[ref].rest
4492                    if ages_capital != 0 and future_capital != 0 and future_rest != 0:
4493                        if debug:
4494                            print('================================================================')
4495                            print('ages', ages_capital, ages_rest)
4496                            print('future', future_capital, future_rest)
4497                        if ages_rest == 0:
4498                            assert ages_capital == future_capital
4499                        elif ages_rest < 0:
4500                            assert -ages_capital == future_capital
4501                        elif ages_rest > 0:
4502                            assert ages_capital == ages_rest + future_capital
4503                self.reset()
4504                assert len(self.__vault.history) == 0
4505
4506            assert self.__history()
4507            assert self.__history(False) is False
4508            assert self.__history() is False
4509            assert self.__history(True)
4510            assert self.__history()
4511            if debug:
4512                print('####################################################################')
4513
4514            wallet_account_id = self.create_account('wallet')
4515            safe_account_id = self.create_account('safe')
4516            bank_account_id = self.create_account('bank')
4517            transaction = [
4518                (
4519                    20, wallet_account_id, AccountID(1), -2000, -2000, -2000, 1, 1,
4520                    2000, 2000, 2000, 1, 1,
4521                ),
4522                (
4523                    750, wallet_account_id, safe_account_id, -77000, -77000, -77000, 2, 2,
4524                    75000, 75000, 75000, 1, 1,
4525                ),
4526                (
4527                    600, safe_account_id, bank_account_id, 15000, 15000, 15000, 1, 2,
4528                    60000, 60000, 60000, 1, 1,
4529                ),
4530            ]
4531            for z in transaction:
4532                lock = self.lock()
4533                x = z[1]
4534                y = z[2]
4535                self.transfer(
4536                    unscaled_amount=z[0],
4537                    from_account=x,
4538                    to_account=y,
4539                    desc='test-transfer',
4540                    debug=debug,
4541                )
4542                zz = self.balance(x)
4543                if debug:
4544                    print(zz, z)
4545                assert zz == z[3]
4546                xx = self.accounts()[x]
4547                assert xx == z[3]
4548                assert self.balance(x, False) == z[4]
4549                assert xx == z[4]
4550
4551                s = 0
4552                log = self.__vault.account[x].log
4553                for i in log:
4554                    s += log[i].value
4555                if debug:
4556                    print('s', s, 'z[5]', z[5])
4557                assert s == z[5]
4558
4559                assert self.box_size(x) == z[6]
4560                assert self.log_size(x) == z[7]
4561
4562                yy = self.accounts()[y]
4563                assert self.balance(y) == z[8]
4564                assert yy == z[8]
4565                assert self.balance(y, False) == z[9]
4566                assert yy == z[9]
4567
4568                s = 0
4569                log = self.__vault.account[y].log
4570                for i in log:
4571                    s += log[i].value
4572                assert s == z[10]
4573
4574                assert self.box_size(y) == z[11]
4575                assert self.log_size(y) == z[12]
4576                assert lock is not None
4577                assert self.free(lock)
4578
4579            if debug:
4580                pp().pprint(self.check(2.17))
4581
4582            assert self.nolock()
4583            history_count = len(self.__vault.history)
4584            transaction_count = len(transaction)
4585            if debug:
4586                print('history-count', history_count, transaction_count)
4587            assert history_count == transaction_count * 3
4588            assert not self.free(Time.time())
4589            assert self.free(self.lock())
4590            assert self.nolock()
4591            assert len(self.__vault.history) == transaction_count * 3
4592
4593            # recall
4594
4595            assert self.nolock()
4596            for i in range(transaction_count * 3, 0, -1):
4597                assert len(self.__vault.history) == i
4598                assert self.recall(dry=False, debug=debug) is True
4599            assert len(self.__vault.history) == 0
4600            assert self.recall(dry=False, debug=debug) is False
4601            assert len(self.__vault.history) == 0
4602
4603            # exchange
4604
4605            cash_account_id = self.create_account('cash')
4606            self.exchange(cash_account_id, 25, 3.75, '2024-06-25')
4607            self.exchange(cash_account_id, 22, 3.73, '2024-06-22')
4608            self.exchange(cash_account_id, 15, 3.69, '2024-06-15')
4609            self.exchange(cash_account_id, 10, 3.66)
4610
4611            assert self.nolock()
4612
4613            bank_account_id = self.create_account('bank')
4614            for i in range(1, 30):
4615                exchange = self.exchange(cash_account_id, i)
4616                rate, description, created = exchange.rate, exchange.description, exchange.time
4617                if debug:
4618                    print(i, rate, description, created)
4619                assert created
4620                if i < 10:
4621                    assert rate == 1
4622                    assert description is None
4623                elif i == 10:
4624                    assert rate == 3.66
4625                    assert description is None
4626                elif i < 15:
4627                    assert rate == 3.66
4628                    assert description is None
4629                elif i == 15:
4630                    assert rate == 3.69
4631                    assert description is not None
4632                elif i < 22:
4633                    assert rate == 3.69
4634                    assert description is not None
4635                elif i == 22:
4636                    assert rate == 3.73
4637                    assert description is not None
4638                elif i >= 25:
4639                    assert rate == 3.75
4640                    assert description is not None
4641                exchange = self.exchange(bank_account_id, i)
4642                rate, description, created = exchange.rate, exchange.description, exchange.time
4643                if debug:
4644                    print(i, rate, description, created)
4645                assert created
4646                assert rate == 1
4647                assert description is None
4648
4649            assert len(self.__vault.exchange) == 1
4650            assert len(self.exchanges()) == 1
4651            self.__vault.exchange.clear()
4652            assert len(self.__vault.exchange) == 0
4653            assert len(self.exchanges()) == 0
4654            self.reset()
4655
4656            # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية
4657            cash_account_id = self.create_account('cash')
4658            self.exchange(cash_account_id, ZakatTracker.day_to_time(25), 3.75, '2024-06-25')
4659            self.exchange(cash_account_id, ZakatTracker.day_to_time(22), 3.73, '2024-06-22')
4660            self.exchange(cash_account_id, ZakatTracker.day_to_time(15), 3.69, '2024-06-15')
4661            self.exchange(cash_account_id, ZakatTracker.day_to_time(10), 3.66)
4662
4663            assert self.nolock()
4664
4665            test_account_id = self.create_account('test')
4666            for i in [x * 0.12 for x in range(-15, 21)]:
4667                if i <= 0:
4668                    assert self.exchange(test_account_id, Time.time(), i, f'range({i})') == Exchange()
4669                else:
4670                    assert self.exchange(test_account_id, Time.time(), i, f'range({i})') is not Exchange()
4671
4672            assert self.nolock()
4673
4674           # اختبار النتائج باستخدام التواريخ بالنانو ثانية
4675            bank_account_id = self.create_account('bank')
4676            for i in range(1, 31):
4677                timestamp_ns = ZakatTracker.day_to_time(i)
4678                exchange = self.exchange(cash_account_id, timestamp_ns)
4679                rate, description, created = exchange.rate, exchange.description, exchange.time
4680                if debug:
4681                    print(i, rate, description, created)
4682                assert created
4683                if i < 10:
4684                    assert rate == 1
4685                    assert description is None
4686                elif i == 10:
4687                    assert rate == 3.66
4688                    assert description is None
4689                elif i < 15:
4690                    assert rate == 3.66
4691                    assert description is None
4692                elif i == 15:
4693                    assert rate == 3.69
4694                    assert description is not None
4695                elif i < 22:
4696                    assert rate == 3.69
4697                    assert description is not None
4698                elif i == 22:
4699                    assert rate == 3.73
4700                    assert description is not None
4701                elif i >= 25:
4702                    assert rate == 3.75
4703                    assert description is not None
4704                exchange = self.exchange(bank_account_id, i)
4705                rate, description, created = exchange.rate, exchange.description, exchange.time
4706                if debug:
4707                    print(i, rate, description, created)
4708                assert created
4709                assert rate == 1
4710                assert description is None
4711
4712            assert self.nolock()
4713            if debug:
4714                print(self.__vault.history, len(self.__vault.history))
4715            for _ in range(len(self.__vault.history)):
4716                assert self.recall(dry=False, debug=debug)
4717            assert not self.recall(dry=False, debug=debug)
4718
4719            self.reset()
4720
4721            # test transfer between accounts with different exchange rate
4722
4723            a_SAR = self.create_account('Bank (SAR)')
4724            b_USD = self.create_account('Bank (USD)')
4725            c_SAR = self.create_account('Safe (SAR)')
4726            # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer
4727            for case in [
4728                (0, a_SAR, 'SAR Gift', 1000, 100000),
4729                (1, a_SAR, 1),
4730                (0, b_USD, 'USD Gift', 500, 50000),
4731                (1, b_USD, 1),
4732                (2, b_USD, 3.75),
4733                (1, b_USD, 3.75),
4734                (3, 100, b_USD, a_SAR, '100 USD -> SAR', 40000, 137500),
4735                (0, c_SAR, 'Salary', 750, 75000),
4736                (3, 375, c_SAR, b_USD, '375 SAR -> USD', 37500, 50000),
4737                (3, 3.75, a_SAR, b_USD, '3.75 SAR -> USD', 137125, 50100),
4738            ]:
4739                if debug:
4740                    print('case', case)
4741                match (case[0]):
4742                    case 0:  # track
4743                        _, account, desc, x, balance = case
4744                        self.track(unscaled_value=x, desc=desc, account=account, debug=debug)
4745
4746                        cached_value = self.balance(account, cached=True)
4747                        fresh_value = self.balance(account, cached=False)
4748                        if debug:
4749                            print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value)
4750                        assert cached_value == balance
4751                        assert fresh_value == balance
4752                    case 1:  # check-exchange
4753                        _, account, expected_rate = case
4754                        t_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug)
4755                        if debug:
4756                            print('t-exchange', t_exchange)
4757                        assert t_exchange.rate == expected_rate
4758                    case 2:  # do-exchange
4759                        _, account, rate = case
4760                        self.exchange(account, rate=rate, debug=debug)
4761                        b_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug)
4762                        if debug:
4763                            print('b-exchange', b_exchange)
4764                        assert b_exchange.rate == rate
4765                    case 3:  # transfer
4766                        _, x, a, b, desc, a_balance, b_balance = case
4767                        self.transfer(x, a, b, desc, debug=debug)
4768
4769                        cached_value = self.balance(a, cached=True)
4770                        fresh_value = self.balance(a, cached=False)
4771                        if debug:
4772                            print(
4773                                'account', a,
4774                                'cached_value', cached_value,
4775                                'fresh_value', fresh_value,
4776                                'a_balance', a_balance,
4777                            )
4778                        assert cached_value == a_balance
4779                        assert fresh_value == a_balance
4780
4781                        cached_value = self.balance(b, cached=True)
4782                        fresh_value = self.balance(b, cached=False)
4783                        if debug:
4784                            print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value)
4785                        assert cached_value == b_balance
4786                        assert fresh_value == b_balance
4787
4788            # Transfer all in many chunks randomly from B to A
4789            a_SAR_balance = 137125
4790            b_USD_balance = 50100
4791            b_USD_exchange = self.exchange(b_USD)
4792            amounts = ZakatTracker.create_random_list(b_USD_balance, max_value=1000)
4793            if debug:
4794                print('amounts', amounts)
4795            i = 0
4796            for x in amounts:
4797                if debug:
4798                    print(f'{i} - transfer-with-exchange({x})')
4799                self.transfer(
4800                    unscaled_amount=self.unscale(x),
4801                    from_account=b_USD,
4802                    to_account=a_SAR,
4803                    desc=f'{x} USD -> SAR',
4804                    debug=debug,
4805                )
4806
4807                b_USD_balance -= x
4808                cached_value = self.balance(b_USD, cached=True)
4809                fresh_value = self.balance(b_USD, cached=False)
4810                if debug:
4811                    print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
4812                          b_USD_balance)
4813                assert cached_value == b_USD_balance
4814                assert fresh_value == b_USD_balance
4815
4816                a_SAR_balance += int(x * b_USD_exchange.rate)
4817                cached_value = self.balance(a_SAR, cached=True)
4818                fresh_value = self.balance(a_SAR, cached=False)
4819                if debug:
4820                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
4821                          a_SAR_balance, 'rate', b_USD_exchange.rate)
4822                assert cached_value == a_SAR_balance
4823                assert fresh_value == a_SAR_balance
4824                i += 1
4825
4826            # Transfer all in many chunks randomly from C to A
4827            c_SAR_balance = 37500
4828            amounts = ZakatTracker.create_random_list(c_SAR_balance, max_value=1000)
4829            if debug:
4830                print('amounts', amounts)
4831            i = 0
4832            for x in amounts:
4833                if debug:
4834                    print(f'{i} - transfer-with-exchange({x})')
4835                self.transfer(
4836                    unscaled_amount=self.unscale(x),
4837                    from_account=c_SAR,
4838                    to_account=a_SAR,
4839                    desc=f'{x} SAR -> a_SAR',
4840                    debug=debug,
4841                )
4842
4843                c_SAR_balance -= x
4844                cached_value = self.balance(c_SAR, cached=True)
4845                fresh_value = self.balance(c_SAR, cached=False)
4846                if debug:
4847                    print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
4848                          c_SAR_balance)
4849                assert cached_value == c_SAR_balance
4850                assert fresh_value == c_SAR_balance
4851
4852                a_SAR_balance += x
4853                cached_value = self.balance(a_SAR, cached=True)
4854                fresh_value = self.balance(a_SAR, cached=False)
4855                if debug:
4856                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
4857                          a_SAR_balance)
4858                assert cached_value == a_SAR_balance
4859                assert fresh_value == a_SAR_balance
4860                i += 1
4861
4862            assert self.save(f'accounts-transfer-with-exchange-rates.{self.ext()}')
4863
4864            # check & zakat with exchange rates for many cycles
4865
4866            lock = None
4867            safe_account_id = self.create_account('safe')
4868            cave_account_id = self.create_account('cave')
4869            for rate, values in {
4870                1: {
4871                    'in': [1000, 2000, 10000],
4872                    'exchanged': [100000, 200000, 1000000],
4873                    'out': [2500, 5000, 73140],
4874                },
4875                3.75: {
4876                    'in': [200, 1000, 5000],
4877                    'exchanged': [75000, 375000, 1875000],
4878                    'out': [1875, 9375, 137138],
4879                },
4880            }.items():
4881                a, b, c = values['in']
4882                m, n, o = values['exchanged']
4883                x, y, z = values['out']
4884                if debug:
4885                    print('rate', rate, 'values', values)
4886                for case in [
4887                    (a, safe_account_id, Time.time() - ZakatTracker.TimeCycle(), [
4888                        {safe_account_id: {0: {'below_nisab': x}}},
4889                    ], False, m),
4890                    (b, safe_account_id, Time.time() - ZakatTracker.TimeCycle(), [
4891                        {safe_account_id: {0: {'count': 1, 'total': y}}},
4892                    ], True, n),
4893                    (c, cave_account_id, Time.time() - (ZakatTracker.TimeCycle() * 3), [
4894                        {cave_account_id: {0: {'count': 3, 'total': z}}},
4895                    ], True, o),
4896                ]:
4897                    if debug:
4898                        print(f'############# check(rate: {rate}) #############')
4899                        print('case', case)
4900                    self.reset()
4901                    self.exchange(account=case[1], created_time_ns=case[2], rate=rate)
4902                    self.track(
4903                        unscaled_value=case[0],
4904                        desc='test-check',
4905                        account=case[1],
4906                        created_time_ns=case[2],
4907                    )
4908                    assert self.snapshot()
4909
4910                    # assert self.nolock()
4911                    # history_size = len(self.__vault.history)
4912                    # print('history_size', history_size)
4913                    # assert history_size == 2
4914                    lock = self.lock()
4915                    assert lock
4916                    assert not self.nolock()
4917                    report = self.check(2.17, None, debug)
4918                    if debug:
4919                        print('report', report)
4920                    assert case[4] == report.valid
4921                    assert case[5] == report.summary.total_wealth
4922                    assert case[5] == report.summary.total_zakatable_amount
4923
4924                    if debug:
4925                        pp().pprint(report.plan)
4926
4927                    for x in report.plan:
4928                        assert case[1] == x
4929                        if report.plan[x][0].below_nisab:
4930                            if debug:
4931                                print('[assert]', report.plan[x][0].total, case[3][0][x][0]['below_nisab'])
4932                            assert report.plan[x][0].total == case[3][0][x][0]['below_nisab']
4933                        else:
4934                            if debug:
4935                                print('[assert]', int(report.summary.total_zakat_due), case[3][0][x][0]['total'])
4936                                print('[assert]', int(report.plan[x][0].total), case[3][0][x][0]['total'])
4937                                print('[assert]', report.plan[x][0].count ,case[3][0][x][0]['count'])
4938                            assert int(report.summary.total_zakat_due) == case[3][0][x][0]['total']
4939                            assert int(report.plan[x][0].total) == case[3][0][x][0]['total']
4940                            assert report.plan[x][0].count == case[3][0][x][0]['count']
4941                    if debug:
4942                        pp().pprint(report)
4943                    result = self.zakat(report, debug=debug)
4944                    if debug:
4945                        print('zakat-result', result, case[4])
4946                    assert result == case[4]
4947                    report = self.check(2.17, None, debug)
4948                    assert report.valid is False
4949
4950            # storage
4951
4952            old_vault = dataclasses.replace(self.__vault)
4953            old_vault_deep = copy.deepcopy(self.__vault)
4954            old_vault_dict = dataclasses.asdict(self.__vault)
4955            _path = self.path(f'./zakat_test_db/test.{self.ext()}')
4956            if os.path.exists(_path):
4957                os.remove(_path)
4958            for hashed in [False, True]:
4959                self.save(hash_required=hashed)
4960                assert os.path.getsize(_path) > 0
4961                self.reset()
4962                assert self.recall(dry=False, debug=debug) is False
4963                for hash_required in [False, True]:
4964                    if debug:
4965                        print(f'[storage] save({hashed}) and load({hash_required}) = {hashed and hash_required}')
4966                    self.load(hash_required=hashed and hash_required)
4967                    if debug:
4968                        print('[debug]', type(self.__vault))
4969                    assert self.__vault.account is not None
4970                    assert old_vault == self.__vault
4971                    assert old_vault_deep == self.__vault
4972                    assert old_vault_dict == dataclasses.asdict(self.__vault)
4973                    # corrupt the data
4974                    log_ref = None
4975                    tmp_file_ref = Time.time()
4976                    for k in self.__vault.account[cave_account_id].log:
4977                        log_ref = k
4978                        self.__vault.account[cave_account_id].log[k].file[tmp_file_ref] = 'HACKED'
4979                        break
4980                    assert old_vault != self.__vault
4981                    assert old_vault_deep != self.__vault
4982                    assert old_vault_dict != dataclasses.asdict(self.__vault)
4983                    # fix the data
4984                    del self.__vault.account[cave_account_id].log[log_ref].file[tmp_file_ref]
4985                    assert old_vault == self.__vault
4986                    assert old_vault_deep == self.__vault
4987                    assert old_vault_dict == dataclasses.asdict(self.__vault)
4988                if hashed:
4989                    continue
4990                failed = False
4991                try:
4992                    hash_required = True
4993                    if debug:
4994                        print(f'x [storage] save({hashed}) and load({hash_required}) = {hashed and hash_required}')
4995                    self.load(hash_required=True)
4996                except:
4997                    failed = True
4998                assert failed
4999
5000            # recall after zakat
5001
5002            history_size = len(self.__vault.history)
5003            if debug:
5004                print('history_size', history_size)
5005            assert history_size == 3
5006            assert not self.nolock()
5007            assert self.recall(dry=False, debug=debug) is False
5008            self.free(lock)
5009            assert self.nolock()
5010
5011            for i in range(3, 0, -1):
5012                history_size = len(self.__vault.history)
5013                if debug:
5014                    print('history_size', history_size)
5015                assert history_size == i
5016                assert self.recall(dry=False, debug=debug) is True
5017
5018            assert self.nolock()
5019            assert self.recall(dry=False, debug=debug) is False
5020
5021            history_size = len(self.__vault.history)
5022            if debug:
5023                print('history_size', history_size)
5024            assert history_size == 0
5025
5026            account_size = len(self.__vault.account)
5027            if debug:
5028                print('account_size', account_size)
5029            assert account_size == 0
5030
5031            report_size = len(self.__vault.report)
5032            if debug:
5033                print('report_size', report_size)
5034            assert report_size == 0
5035
5036            assert self.nolock()
5037
5038            # csv
5039
5040            csv_count = 1000
5041
5042            for with_rate, path in {
5043                False: 'test-import_csv-no-exchange',
5044                True: 'test-import_csv-with-exchange',
5045            }.items():
5046
5047                if debug:
5048                    print('test_import_csv', with_rate, path)
5049
5050                csv_path = path + '.csv'
5051                if os.path.exists(csv_path):
5052                    os.remove(csv_path)
5053                c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug)
5054                if debug:
5055                    print('generate_random_csv_file', c)
5056                assert c == csv_count
5057                assert os.path.getsize(csv_path) > 0
5058                cache_path = self.import_csv_cache_path()
5059                if os.path.exists(cache_path):
5060                    os.remove(cache_path)
5061                self.reset()
5062                lock = self.lock()
5063                import_report = self.import_csv(csv_path, debug=debug)
5064                bad_count = len(import_report.bad)
5065                if debug:
5066                    print(f'csv-imported: {import_report.statistics} = count({csv_count})')
5067                    print('bad', import_report.bad)
5068                assert import_report.statistics.created + import_report.statistics.found + bad_count == csv_count
5069                assert import_report.statistics.created == csv_count
5070                assert bad_count == 0
5071                assert bad_count == import_report.statistics.bad
5072                tmp_size = os.path.getsize(cache_path)
5073                assert tmp_size > 0
5074
5075                import_report_2 = self.import_csv(csv_path, debug=debug)
5076                bad_2_count = len(import_report_2.bad)
5077                if debug:
5078                    print(f'csv-imported: {import_report_2}')
5079                    print('bad', import_report_2.bad)
5080                assert tmp_size == os.path.getsize(cache_path)
5081                assert import_report_2.statistics.created + import_report_2.statistics.found + bad_2_count == csv_count
5082                assert import_report.statistics.created == import_report_2.statistics.found
5083                assert bad_count == bad_2_count
5084                assert import_report_2.statistics.found == csv_count
5085                assert bad_2_count == 0
5086                assert bad_2_count == import_report_2.statistics.bad
5087                assert import_report_2.statistics.created == 0
5088
5089                # payment parts
5090
5091                positive_parts = self.build_payment_parts(100, positive_only=True)
5092                assert self.check_payment_parts(positive_parts) != 0
5093                assert self.check_payment_parts(positive_parts) != 0
5094                all_parts = self.build_payment_parts(300, positive_only=False)
5095                assert self.check_payment_parts(all_parts) != 0
5096                assert self.check_payment_parts(all_parts) != 0
5097                if debug:
5098                    pp().pprint(positive_parts)
5099                    pp().pprint(all_parts)
5100                # dynamic discount
5101                suite = []
5102                count = 3
5103                for exceed in [False, True]:
5104                    case = []
5105                    for part in [positive_parts, all_parts]:
5106                        #part = parts.copy()
5107                        demand = part.demand
5108                        if debug:
5109                            print(demand, part.total)
5110                        i = 0
5111                        z = demand / count
5112                        cp = PaymentParts(
5113                            demand=demand,
5114                            exceed=exceed,
5115                            total=part.total,
5116                        )
5117                        j = ''
5118                        for x, y in part.account.items():
5119                            x_exchange = self.exchange(x)
5120                            zz = self.exchange_calc(z, 1, x_exchange.rate)
5121                            if exceed and zz <= demand:
5122                                i += 1
5123                                y.part = zz
5124                                if debug:
5125                                    print(exceed, y)
5126                                cp.account[x] = y
5127                                case.append(y)
5128                            elif not exceed and y.balance >= zz:
5129                                i += 1
5130                                y.part = zz
5131                                if debug:
5132                                    print(exceed, y)
5133                                cp.account[x] = y
5134                                case.append(y)
5135                            j = x
5136                            if i >= count:
5137                                break
5138                        if debug:
5139                            print('[debug]', j)
5140                            print('[debug]', cp.account[j])
5141                        if cp.account[j] != AccountPaymentPart(0.0, 0.0, 0.0):
5142                            suite.append(cp)
5143                if debug:
5144                    print('suite', len(suite))
5145                for case in suite:
5146                    if debug:
5147                        print('case', case)
5148                    result = self.check_payment_parts(case)
5149                    if debug:
5150                        print('check_payment_parts', result, f'exceed: {exceed}')
5151                    assert result == 0
5152
5153                    report = self.check(2.17, None, debug)
5154                    if debug:
5155                        print('valid', report.valid)
5156                    zakat_result = self.zakat(report, parts=case, debug=debug)
5157                    if debug:
5158                        print('zakat-result', zakat_result)
5159                    assert report.valid == zakat_result
5160
5161                assert self.free(lock)
5162
5163            assert self.save(path + f'.{self.ext()}')
5164
5165            assert self.save(f'1000-transactions-test.{self.ext()}')
5166            return True
5167        except Exception as e:
5168            if self.__debug_output:
5169                pp().pprint(self.__vault)
5170                print('============================================================================')
5171                pp().pprint(self.__debug_output)
5172            assert self.save(f'test-snapshot.{self.ext()}')
5173            raise e
class AccountID(builtins.str):
230class AccountID(str):
231    """
232    A class representing an Account ID, which is a string that must be a positive integer greater than zero.
233    Inherits from str, so it behaves like a string.
234    """
235
236    def __new__(cls, value):
237        """
238        Creates a new AccountID instance.
239
240        Parameters:
241        - value (str): The string value to be used as the AccountID.
242
243        Raises:
244        - ValueError: If the provided value is not a valid AccountID.
245
246        Returns:
247        - AccountID: A new AccountID instance.
248        """
249        if isinstance(value, Timestamp):
250            value = str(value) # convert timestamp to string
251        if not cls.is_valid_account_id(value):
252            raise ValueError(f"Invalid AccountID: '{value}'")
253        return super().__new__(cls, value)
254
255    @staticmethod
256    def is_valid_account_id(s: str) -> bool:
257        """
258        Checks if a string is a valid AccountID (positive integer greater than zero).
259
260        Parameters:
261        - s (str): The string to check.
262
263        Returns:
264         - bool: True if the string is a valid AccountID, False otherwise.
265        """
266        if not s:
267            return False
268
269        try:
270            if s[0] == '0':
271                return False
272            if s.startswith('-'):
273                return False
274            if not s.isdigit():
275                return False
276        except:
277            pass
278
279        try:
280            num = int(s)
281            return num > 0
282        except ValueError:
283            return False
284
285    @classmethod
286    def test(cls, debug: bool = False):
287        """
288        Runs tests for the AccountID class to ensure it behaves correctly.
289
290        This method tests various valid and invalid input strings to verify that:
291            - Valid AccountIDs are created successfully.
292            - Invalid AccountIDs raise ValueError exceptions.
293        """
294        test_data = {
295            "123": True,
296            "0": False,
297            "01": False,
298            "-1": False,
299            "abc": False,
300            "12.3": False,
301            "": False,
302            "9999999999999999999999999999999999999": True,
303            "1": True,
304            "10": True,
305            "000000000000000001": False,
306            " ": False,
307            "1 ": False,
308            " 1": False,
309            "1.0": False,
310            Timestamp(12345): True, # Test timestamp input
311        }
312
313        for input_value, expected_output in test_data.items():
314            if expected_output:
315                try:
316                    account_id = cls(input_value)
317                    if debug:
318                        print(f'"{str(account_id)}", "{input_value}"')
319                    if isinstance(input_value, Timestamp):
320                        input_value = str(input_value)
321                    assert str(account_id) == input_value, f"Test failed for valid input: '{input_value}'"
322                except ValueError as e:
323                    assert False, f"Unexpected ValueError for valid input: '{input_value}': {e}"
324            else:
325                try:
326                    cls(input_value)
327                    assert False, f"Expected ValueError for invalid input: '{input_value}'"
328                except ValueError as e:
329                    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)
236    def __new__(cls, value):
237        """
238        Creates a new AccountID instance.
239
240        Parameters:
241        - value (str): The string value to be used as the AccountID.
242
243        Raises:
244        - ValueError: If the provided value is not a valid AccountID.
245
246        Returns:
247        - AccountID: A new AccountID instance.
248        """
249        if isinstance(value, Timestamp):
250            value = str(value) # convert timestamp to string
251        if not cls.is_valid_account_id(value):
252            raise ValueError(f"Invalid AccountID: '{value}'")
253        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:
255    @staticmethod
256    def is_valid_account_id(s: str) -> bool:
257        """
258        Checks if a string is a valid AccountID (positive integer greater than zero).
259
260        Parameters:
261        - s (str): The string to check.
262
263        Returns:
264         - bool: True if the string is a valid AccountID, False otherwise.
265        """
266        if not s:
267            return False
268
269        try:
270            if s[0] == '0':
271                return False
272            if s.startswith('-'):
273                return False
274            if not s.isdigit():
275                return False
276        except:
277            pass
278
279        try:
280            num = int(s)
281            return num > 0
282        except ValueError:
283            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):
285    @classmethod
286    def test(cls, debug: bool = False):
287        """
288        Runs tests for the AccountID class to ensure it behaves correctly.
289
290        This method tests various valid and invalid input strings to verify that:
291            - Valid AccountIDs are created successfully.
292            - Invalid AccountIDs raise ValueError exceptions.
293        """
294        test_data = {
295            "123": True,
296            "0": False,
297            "01": False,
298            "-1": False,
299            "abc": False,
300            "12.3": False,
301            "": False,
302            "9999999999999999999999999999999999999": True,
303            "1": True,
304            "10": True,
305            "000000000000000001": False,
306            " ": False,
307            "1 ": False,
308            " 1": False,
309            "1.0": False,
310            Timestamp(12345): True, # Test timestamp input
311        }
312
313        for input_value, expected_output in test_data.items():
314            if expected_output:
315                try:
316                    account_id = cls(input_value)
317                    if debug:
318                        print(f'"{str(account_id)}", "{input_value}"')
319                    if isinstance(input_value, Timestamp):
320                        input_value = str(input_value)
321                    assert str(account_id) == input_value, f"Test failed for valid input: '{input_value}'"
322                except ValueError as e:
323                    assert False, f"Unexpected ValueError for valid input: '{input_value}': {e}"
324            else:
325                try:
326                    cls(input_value)
327                    assert False, f"Expected ValueError for invalid input: '{input_value}'"
328                except ValueError as e:
329                    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.

class Timestamp(builtins.int):
168class Timestamp(int):
169    """Represents a timestamp as an integer, which must be greater than zero."""
170
171    def __new__(cls, value):
172        """
173        Creates a new Timestamp instance.
174
175        Parameters:
176        - value (int or str): The integer value to be used as the timestamp.
177
178        Raises:
179        - TypeError: If the provided value is not an integer or a string representing an integer.
180        - ValueError: If the provided value is not greater than zero.
181
182        Returns:
183        - Timestamp: A new Timestamp instance.
184        """
185        if isinstance(value, str):
186            try:
187                value = int(value)
188            except ValueError:
189                raise TypeError(f"String value must represent an integer, instead ({type(value)}: {value}) is given.")
190        if not isinstance(value, int):
191            raise TypeError(f"Timestamp value must be an integer, instead ({type(value)}: {value}) is given.")
192
193        if value <= 0:
194            raise ValueError("Timestamp value must be greater than zero.")
195
196        return super().__new__(cls, value)
197
198    @classmethod
199    def test(cls):
200        """
201        Runs tests for the Timestamp class to ensure it behaves correctly.
202        """
203        test_data = {
204            123: True,
205            "123": True,
206            0: False,
207            "0": False,
208            -1: False,
209            "-1": False,
210            "abc": False,
211            1: True,
212            "1": True,
213        }
214
215        for input_value, expected_output in test_data.items():
216            if expected_output:
217                try:
218                    timestamp = cls(input_value)
219                    assert int(timestamp) == int(input_value), f"Test failed for valid input: '{input_value}'"
220                except (TypeError, ValueError) as e:
221                    assert False, f"Unexpected error for valid input: '{input_value}': {e}"
222            else:
223                try:
224                    cls(input_value)
225                    assert False, f"Expected error for invalid input: '{input_value}'"
226                except (TypeError, ValueError):
227                    pass  # Expected exception

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

Timestamp(value)
171    def __new__(cls, value):
172        """
173        Creates a new Timestamp instance.
174
175        Parameters:
176        - value (int or str): The integer value to be used as the timestamp.
177
178        Raises:
179        - TypeError: If the provided value is not an integer or a string representing an integer.
180        - ValueError: If the provided value is not greater than zero.
181
182        Returns:
183        - Timestamp: A new Timestamp instance.
184        """
185        if isinstance(value, str):
186            try:
187                value = int(value)
188            except ValueError:
189                raise TypeError(f"String value must represent an integer, instead ({type(value)}: {value}) is given.")
190        if not isinstance(value, int):
191            raise TypeError(f"Timestamp value must be an integer, instead ({type(value)}: {value}) is given.")
192
193        if value <= 0:
194            raise ValueError("Timestamp value must be greater than zero.")
195
196        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):
198    @classmethod
199    def test(cls):
200        """
201        Runs tests for the Timestamp class to ensure it behaves correctly.
202        """
203        test_data = {
204            123: True,
205            "123": True,
206            0: False,
207            "0": False,
208            -1: False,
209            "-1": False,
210            "abc": False,
211            1: True,
212            "1": True,
213        }
214
215        for input_value, expected_output in test_data.items():
216            if expected_output:
217                try:
218                    timestamp = cls(input_value)
219                    assert int(timestamp) == int(input_value), f"Test failed for valid input: '{input_value}'"
220                except (TypeError, ValueError) as e:
221                    assert False, f"Unexpected error for valid input: '{input_value}': {e}"
222            else:
223                try:
224                    cls(input_value)
225                    assert False, f"Expected error for invalid input: '{input_value}'"
226                except (TypeError, ValueError):
227                    pass  # Expected exception

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

@dataclasses.dataclass
class Box(zakat.StrictDataclass):
425@dataclasses.dataclass
426class Box(
427        StrictDataclass,
428        # ImmutableWithSelectiveFreeze,
429    ):
430    """
431    Represents a financial box with capital, remaining value, and zakat details.
432
433    Attributes:
434    - capital (int): The initial capital value of the box.
435    - rest (int): The current remaining value within the box.
436    - zakat (BoxZakat): A `BoxZakat` object containing the accumulated zakat information for the box.
437    """
438    capital: int #= dataclasses.field(metadata={"frozen": True})
439    rest: int
440    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):
443@dataclasses.dataclass
444class Log(StrictDataclass):
445    """
446    Represents a log entry for an account.
447
448    Attributes:
449    - value: The value of the log entry.
450    - desc: A description of the log entry.
451    - ref: An optional timestamp reference.
452    - file: A dictionary mapping timestamps to file paths.
453    """
454    value: int
455    desc: str
456    ref: Optional[Timestamp]
457    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):
460@dataclasses.dataclass
461class Account(StrictDataclass):
462    """
463    Represents a financial account.
464
465    Attributes:
466    - balance: The current balance of the account.
467    - created: The timestamp when the account was created.
468    - name: The name of the account.
469    - box: A dictionary mapping timestamps to Box objects.
470    - count: A counter for logs, initialized to 0.
471    - log: A dictionary mapping timestamps to Log objects.
472    - hide: A boolean indicating whether the account is hidden.
473    - zakatable: A boolean indicating whether the account is subject to zakat.
474    """
475    balance: int
476    created: Timestamp
477    name: str = ''
478    box: dict[Timestamp, Box] = dataclasses.field(default_factory=dict)
479    count: int = dataclasses.field(default_factory=factory_value(0))
480    log: dict[Timestamp, Log] = dataclasses.field(default_factory=dict)
481    hide: bool = dataclasses.field(default_factory=factory_value(False))
482    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):
485@dataclasses.dataclass
486class Exchange(StrictDataclass):
487    """
488    Represents an exchange rate and related information.
489
490    Attributes:
491    - rate: The exchange rate (optional).
492    - description: A description of the exchange (optional).
493    - time: The timestamp of the exchange (optional).
494    """
495    rate: Optional[float] = None
496    description: Optional[str] = None
497    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):
500@dataclasses.dataclass
501class History(StrictDataclass):
502    """
503    Represents a history entry for an account action.
504
505    Attributes:
506    - action: The action performed.
507    - account: The ID of the account (optional).
508    - ref: An optional timestamp reference.
509    - file: An optional timestamp for a file.
510    - key: An optional key.
511    - value: An optional value.
512    - math: An optional math operation.
513    """
514    action: Action
515    account: Optional[AccountID]
516    ref: Optional[Timestamp]
517    file: Optional[Timestamp]
518    key: Optional[str]
519    value: Optional[any] # !!!
520    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):
586@dataclasses.dataclass
587class Vault(StrictDataclass):
588    """
589    Represents a vault containing accounts, exchanges, and history.
590
591    Attributes:
592    - account: A dictionary mapping account IDs to Account objects.
593    - exchange: A dictionary mapping account IDs to dictionaries of timestamps and Exchange objects.
594    - history: A dictionary mapping timestamps to dictionaries of History objects.
595    - lock: An optional timestamp for a lock.
596    - report: A dictionary mapping timestamps to tuples.
597    """
598    account: dict[AccountID, Account] = dataclasses.field(default_factory=dict)
599    exchange: dict[AccountID, dict[Timestamp, Exchange]] = dataclasses.field(default_factory=dict)
600    history: dict[Timestamp, dict[Timestamp, History]] = dataclasses.field(default_factory=dict)
601    lock: Optional[Timestamp] = None
602    report: dict[Timestamp, ZakatReport] = dataclasses.field(default_factory=dict)

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.
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>)
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):
605@dataclasses.dataclass
606class AccountPaymentPart(StrictDataclass):
607    """
608    Represents a payment part for an account.
609
610    Attributes:
611    - balance: The balance of the payment part.
612    - rate: The rate of the payment part.
613    - part: The part of the payment.
614    """
615    balance: float
616    rate: float
617    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):
620@dataclasses.dataclass
621class PaymentParts(StrictDataclass):
622    """
623    Represents payment parts for multiple accounts.
624
625    Attributes:
626    - exceed: A boolean indicating whether the payment exceeds a limit.
627    - demand: The demand for payment.
628    - total: The total payment.
629    - account: A dictionary mapping account references to AccountPaymentPart objects.
630    """
631    exceed: bool
632    demand: int
633    total: float
634    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):
637@dataclasses.dataclass
638class SubtractAge(StrictDataclass):
639    """
640    Represents an age subtraction.
641
642    Attributes:
643    - box_ref: The timestamp reference for the box.
644    - total: The total amount to subtract.
645    """
646    box_ref: Timestamp
647    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]):
650@dataclasses.dataclass
651class SubtractAges(StrictDataclass, list[SubtractAge]):
652    """A list of SubtractAge objects."""
653    pass

A list of SubtractAge objects.

@dataclasses.dataclass
class SubtractReport(zakat.StrictDataclass):
656@dataclasses.dataclass
657class SubtractReport(StrictDataclass):
658    """
659    Represents a report of age subtractions.
660
661    Attributes:
662    - log_ref: The timestamp reference for the log.
663    - ages: A list of SubtractAge objects.
664    """
665    log_ref: Timestamp
666    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):
669@dataclasses.dataclass
670class TransferTime(StrictDataclass):
671    """
672    Represents a transfer time.
673
674    Attributes:
675    - box_ref: The timestamp reference for the box.
676    - log_ref: The timestamp reference for the log.
677    """
678    box_ref: Timestamp
679    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]):
682@dataclasses.dataclass
683class TransferTimes(StrictDataclass, list[TransferTime]):
684    """A list of TransferTime objects."""
685    pass

A list of TransferTime objects.

@dataclasses.dataclass
class TransferRecord(zakat.StrictDataclass):
688@dataclasses.dataclass
689class TransferRecord(StrictDataclass):
690    """
691    Represents a transfer record.
692
693    Attributes:
694    - box_ref: The timestamp reference for the box.
695    - times: A list of TransferTime objects.
696    """
697    box_ref: Timestamp
698    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]):
701class TransferReport(StrictDataclass, list[TransferRecord]):
702    """A list of TransferRecord objects."""
703    pass

A list of TransferRecord objects.

@dataclasses.dataclass
class BoxPlan(zakat.StrictDataclass):
523@dataclasses.dataclass
524class BoxPlan(StrictDataclass):
525    """
526    Represents a plan for a box.
527
528    Attributes:
529    - box: The Box object.
530    - log: The Log object.
531    - exchange: The Exchange object.
532    - below_nisab: A boolean indicating whether the value is below nisab.
533    - total: The total value.
534    - count: The count.
535    - ref: The timestamp reference.
536    """
537    box: Box
538    log: Log
539    exchange: Exchange
540    below_nisab: bool
541    total: float
542    count: int
543    ref: Timestamp

Represents a plan for a box.

Attributes:

  • box: The Box object.
  • log: The Log object.
  • exchange: The Exchange object.
  • below_nisab: A boolean indicating whether the value is below nisab.
  • total: The total value.
  • count: The count.
  • ref: The timestamp reference.
BoxPlan( box: Box, log: Log, exchange: Exchange, below_nisab: bool, total: float, count: int, ref: Timestamp)
box: Box
log: Log
exchange: Exchange
below_nisab: bool
total: float
count: int
ref: Timestamp
@dataclasses.dataclass
class ZakatPlan(zakat.StrictDataclass, dict[zakat.zakat_tracker.AccountID, list[zakat.zakat_tracker.BoxPlan]]):
546@dataclasses.dataclass
547class ZakatPlan(StrictDataclass, dict[AccountID, list[BoxPlan]]):
548    """A dictionary mapping account IDs to lists of BoxPlan objects."""
549    pass

A dictionary mapping account IDs to lists of BoxPlan objects.

@dataclasses.dataclass
class ZakatSummary(zakat.StrictDataclass):
552@dataclasses.dataclass
553class ZakatSummary(StrictDataclass):
554    """
555    Summarizes key financial figures for a Zakat calculation.
556
557    Attributes:
558    - total_wealth (int): The total wealth collected from all rest of transactions.
559    - num_wealth_items (int): The number of individual transactions contributing to the total wealth.
560    - num_zakatable_items (int): The number of transactions subject to Zakat.
561    - total_zakatable_amount (int): The total value of all transactions subject to Zakat.
562    - total_zakat_due (int): The calculated amount of Zakat payable.
563    """
564    total_wealth: int = 0
565    num_wealth_items: int = 0
566    num_zakatable_items: int = 0
567    total_zakatable_amount: int = 0
568    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):
571@dataclasses.dataclass
572class ZakatReport(StrictDataclass):
573    """
574    Represents a zakat report.
575
576    Attributes:
577    - valid: A boolean indicating whether the Zakat is available.
578    - summary: The ZakatSummary object.
579    - plan: The ZakatPlan object.
580    """
581    valid: bool
582    summary: ZakatSummary
583    plan: ZakatPlan

Represents a zakat report.

Attributes:

  • valid: A boolean indicating whether the Zakat is available.
  • summary: The ZakatSummary object.
  • plan: The ZakatPlan object.
ZakatReport( valid: bool, summary: ZakatSummary, plan: ZakatPlan)
valid: bool
summary: ZakatSummary
plan: ZakatPlan
def test(path: Optional[str] = None, debug: bool = False):
5176def test(path: Optional[str] = None, debug: bool = False):
5177    """
5178    Executes a test suite for the ZakatTracker.
5179
5180    This function initializes a ZakatTracker instance, optionally using a specified
5181    database path or a temporary directory. It then runs the test suite and, if debug
5182    mode is enabled, prints detailed test results and execution time.
5183
5184    Parameters:
5185    - path (str, optional): The path to the ZakatTracker database. If None, a
5186                            temporary directory is created. Defaults to None.
5187    - debug (bool, optional): Enables debug mode, which prints detailed test
5188                            results and execution time. Defaults to False.
5189
5190    Returns:
5191    None. The function asserts the result of the ZakatTracker's test suite.
5192
5193    Raises:
5194    - AssertionError: If the ZakatTracker's test suite fails.
5195
5196    Examples:
5197    - `test()` Runs tests using a temporary database.
5198    - `test(debug=True)` Runs the test suite in debug mode with a temporary directory.
5199    - `test(path="/path/to/my/db")` Runs tests using a specified database path.
5200    - `test(path="/path/to/my/db", debug=False)` Runs test suite with specified path.
5201    """
5202    no_path = path is None
5203    if no_path:
5204        path = tempfile.mkdtemp()
5205        print(f"Random database path {path}")
5206    if os.path.exists(path):
5207        shutil.rmtree(path)
5208    assert ZakatTracker(':memory:').memory_mode()
5209    ledger = ZakatTracker(
5210        db_path=path,
5211        history_mode=True,
5212    )
5213    start = time.time_ns()
5214    assert not ledger.memory_mode()
5215    assert ledger.test(debug=debug)
5216    if no_path and os.path.exists(path):
5217        shutil.rmtree(path)
5218    if debug:
5219        print('#########################')
5220        print('######## TEST DONE ########')
5221        print('#########################')
5222        print(Time.duration_from_nanoseconds(time.time_ns() - start))
5223        print('#########################')

Executes a test suite for the ZakatTracker.

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

Parameters:

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

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

Raises:

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

Examples:

  • test() Runs tests using a temporary database.
  • test(debug=True) Runs the test suite in debug mode with a temporary directory.
  • test(path="/path/to/my/db") Runs tests using a specified database path.
  • test(path="/path/to/my/db", debug=False) Runs test suite with specified path.
@enum.unique
class Action(enum.Enum):
104@enum.unique
105class Action(enum.Enum):
106    """
107    Enumeration representing various actions that can be performed.
108
109    Members:
110    - CREATE: Represents the creation action ('CREATE').
111    - NAME: Represents the renaming action ('NAME').
112    - TRACK: Represents the tracking action ('TRACK').
113    - LOG: Represents the logging action ('LOG').
114    - SUBTRACT: Represents the subtract action ('SUBTRACT').
115    - ADD_FILE: Represents the action of adding a file ('ADD_FILE').
116    - REMOVE_FILE: Represents the action of removing a file ('REMOVE_FILE').
117    - BOX_TRANSFER: Represents the action of transferring a box ('BOX_TRANSFER').
118    - EXCHANGE: Represents the exchange action ('EXCHANGE').
119    - REPORT: Represents the reporting action ('REPORT').
120    - ZAKAT: Represents a Zakat related action ('ZAKAT').
121    """
122    CREATE = 'CREATE'
123    NAME = 'NAME'
124    TRACK = 'TRACK'
125    LOG = 'LOG'
126    SUBTRACT = 'SUBTRACT'
127    ADD_FILE = 'ADD_FILE'
128    REMOVE_FILE = 'REMOVE_FILE'
129    BOX_TRANSFER = 'BOX_TRANSFER'
130    EXCHANGE = 'EXCHANGE'
131    REPORT = 'REPORT'
132    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):
842class JSONEncoder(json.JSONEncoder):
843    """
844    Custom JSON encoder to handle specific object types.
845
846    This encoder overrides the default `default` method to serialize:
847    - `Action` and `MathOperation` enums as their member names.
848    - `decimal.Decimal` instances as floats.
849
850    Example:
851    ```bash
852    >>> json.dumps(Action.CREATE, cls=JSONEncoder)
853    'CREATE'
854    >>> json.dumps(decimal.Decimal('10.5'), cls=JSONEncoder)
855    '10.5'
856    ```
857    """
858    def default(self, o):
859        """
860        Overrides the default `default` method to serialize specific object types.
861
862        Parameters:
863        - o: The object to serialize.
864
865        Returns:
866        - The serialized object.
867        """
868        if isinstance(o, (Action, MathOperation)):
869            return o.name  # Serialize as the enum member's name
870        if isinstance(o, decimal.Decimal):
871            return float(o)
872        if isinstance(o, Exception):
873            return str(o)
874        if isinstance(o, Vault) or isinstance(o, ImportReport):
875            return dataclasses.asdict(o)
876        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):
858    def default(self, o):
859        """
860        Overrides the default `default` method to serialize specific object types.
861
862        Parameters:
863        - o: The object to serialize.
864
865        Returns:
866        - The serialized object.
867        """
868        if isinstance(o, (Action, MathOperation)):
869            return o.name  # Serialize as the enum member's name
870        if isinstance(o, decimal.Decimal):
871            return float(o)
872        if isinstance(o, Exception):
873            return str(o)
874        if isinstance(o, Vault) or isinstance(o, ImportReport):
875            return dataclasses.asdict(o)
876        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):
879class JSONDecoder(json.JSONDecoder):
880    """
881    Custom JSON decoder to handle specific object types.
882
883    This decoder overrides the `object_hook` method to deserialize:
884    - Strings representing enum member names back to their respective enum values.
885    - Floats back to `decimal.Decimal` instances.
886
887    Example:
888    ```bash
889    >>> json.loads('{"action": "CREATE"}', cls=JSONDecoder)
890    {'action': <Action.CREATE: 1>}
891    >>> json.loads('{"value": 10.5}', cls=JSONDecoder)
892    {'value': Decimal('10.5')}
893    ```
894    """
895    def object_hook(self, obj):
896        """
897        Overrides the default `object_hook` method to deserialize specific object types.
898
899        Parameters:
900        - obj: The object to deserialize.
901
902        Returns:
903        - The deserialized object.
904        """
905        if isinstance(obj, str) and obj in Action.__members__:
906            return Action[obj]
907        if isinstance(obj, str) and obj in MathOperation.__members__:
908            return MathOperation[obj]
909        if isinstance(obj, float):
910            return decimal.Decimal(str(obj))
911        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):
895    def object_hook(self, obj):
896        """
897        Overrides the default `object_hook` method to deserialize specific object types.
898
899        Parameters:
900        - obj: The object to deserialize.
901
902        Returns:
903        - The deserialized object.
904        """
905        if isinstance(obj, str) and obj in Action.__members__:
906            return Action[obj]
907        if isinstance(obj, str) and obj in MathOperation.__members__:
908            return MathOperation[obj]
909        if isinstance(obj, float):
910            return decimal.Decimal(str(obj))
911        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):
135@enum.unique
136class MathOperation(enum.Enum):
137    """
138    Enumeration representing mathematical operations.
139
140    Members:
141    - ADDITION: Represents the addition operation ('ADDITION').
142    - EQUAL: Represents the equality operation ('EQUAL').
143    - SUBTRACTION: Represents the subtraction operation ('SUBTRACTION').
144    """
145    ADDITION = 'ADDITION'
146    EQUAL = 'EQUAL'
147    SUBTRACTION = 'SUBTRACTION'

Enumeration representing mathematical operations.

Members:

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

Enumeration representing the days of the week.

Members:

  • MONDAY: Represents Monday (0).
  • TUESDAY: Represents Tuesday (1).
  • WEDNESDAY: Represents Wednesday (2).
  • THURSDAY: Represents Thursday (3).
  • FRIDAY: Represents Friday (4).
  • SATURDAY: Represents Saturday (5).
  • SUNDAY: Represents Sunday (6).
MONDAY = <WeekDay.MONDAY: 0>
TUESDAY = <WeekDay.TUESDAY: 1>
WEDNESDAY = <WeekDay.WEDNESDAY: 2>
THURSDAY = <WeekDay.THURSDAY: 3>
FRIDAY = <WeekDay.FRIDAY: 4>
SATURDAY = <WeekDay.SATURDAY: 5>
SUNDAY = <WeekDay.SUNDAY: 6>
def start_file_server( database_path: str, database_callback: Optional[<built-in function callable>] = None, csv_callback: Optional[<built-in function callable>] = None, debug: bool = False) -> tuple:
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:
339@dataclasses.dataclass
340class StrictDataclass:
341    """A dataclass that prevents setting non-existent attributes."""
342    def __setattr__(self, name: str, value: any) -> None:
343        _check_attribute(self, name, value)

A dataclass that prevents setting non-existent attributes.

class ImmutableWithSelectiveFreeze:
346class ImmutableWithSelectiveFreeze:
347    """
348    A base class for creating immutable objects with the ability to selectively
349    freeze specific fields.
350
351    Inheriting from this class will automatically make all fields defined in
352    dataclasses as frozen after initialization if their metadata contains
353    `"frozen": True`. Attempting to set a value to a frozen field after
354    initialization will raise a RuntimeError.
355
356    Example:
357    ```python
358    @dataclasses.dataclass
359    class MyObject(ImmutableWithSelectiveFreeze):
360        name: str
361        count: int = dataclasses.field(metadata={"frozen": True})
362        description: str = "default"
363
364    obj = MyObject(name="Test", count=5)
365    print(obj.name)  # Output: Test
366    print(obj.count) # Output: 5
367    obj.name = "New Name" # This will work
368    try:
369        obj.count = 10  # This will raise a RuntimeError
370    except RuntimeError as e:
371        print(e)      # Output: Field 'count' is frozen!
372    print(obj.description) # Output: default
373    obj.description = "updated" # This will work
374    ```
375    """
376    # Implementation based on: https://discuss.python.org/t/dataclasses-freezing-specific-fields-should-be-possible/59968/2
377    def __post_init__(self):
378        """
379        Initializes the object and freezes fields marked with `"frozen": True`
380        in their metadata.
381        """
382        self.__set_fields_frozen(self)
383
384    @classmethod
385    def __set_fields_frozen(cls, self):
386        """
387        Iterates through the dataclass fields and freezes those with the
388        `"frozen": True` metadata.
389        """
390        flds = dataclasses.fields(cls)
391        for fld in flds:
392            if fld.metadata.get("frozen"):
393                field_name = fld.name
394                field_value = getattr(self, fld.name)
395                setattr(self, f"_{fld.name}", field_value)
396
397                def local_getter(self):
398                    """Getter for the frozen field."""
399                    return getattr(self, f"_{field_name}")
400
401                def frozen(name):
402                    """Creates a setter that raises a RuntimeError for frozen fields."""
403                    def local_setter(self, value):
404                        raise RuntimeError(f"Field '{name}' is frozen!")
405                    return local_setter
406
407                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