zakat
xxx

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

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

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


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

CodeRabbit Pull Request Reviews ar

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

Get Started:

Install the Zakat library using pip:

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

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

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

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

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

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

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

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


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

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

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

Key Features:

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

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

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

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

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

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

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

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

Benefits:

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

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

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

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

Customizable:

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

Who Can Benefit:

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

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

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

Documentation

Videos:

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

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

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:
1730    def free(self, lock: Timestamp, auto_save: bool = True) -> bool:
1731        """
1732        Releases the lock on the database.
1733
1734        Parameters:
1735        - lock (Timestamp): The lock ID to be released.
1736        - auto_save (bool, optional): Whether to automatically save the database after releasing the lock.
1737
1738        Returns:
1739        - bool: True if the lock is successfully released and (optionally) saved, False otherwise.
1740        """
1741        if lock == self.__vault.lock:
1742            self.clean_history(lock)
1743            self.__vault.lock = None
1744            if auto_save and not self.memory_mode():
1745                return self.save(self.path())
1746            return True
1747        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:
1749    def recall(self, dry: bool = True, lock: Optional[Timestamp] = None, debug: bool = False) -> bool:
1750        """
1751        Revert the last operation.
1752
1753        Parameters:
1754        - dry (bool): If True, the function will not modify the data, but will simulate the operation. Default is True.
1755        - lock (Timestamp, optional): An optional lock value to ensure the recall
1756                operation is performed on the expected history entry. If provided,
1757                it checks if the current lock and the most recent history key
1758                match the given lock value. Defaults to None.
1759        - debug (bool, optional): If True, the function will print debug information. Default is False.
1760
1761        Returns:
1762        - bool: True if the operation was successful, False otherwise.
1763        """
1764        if not self.nolock() or len(self.__vault.history) == 0:
1765            return False
1766        if len(self.__vault.history) <= 0:
1767            return False
1768        ref = sorted(self.__vault.history.keys())[-1]
1769        if debug:
1770            print('recall', ref)
1771        memory = sorted(self.__vault.history[ref], reverse=True)
1772        if debug:
1773            print(type(memory), 'memory', memory)
1774        if lock is not None:
1775            assert self.__vault.lock == lock, "Invalid current lock"
1776            assert ref == lock, "Invalid last lock"
1777            assert self.__history(), "History mode should be enabled, found off!!!"
1778        sub_positive_log_negative = 0
1779        for i in memory:
1780            x = self.__vault.history[ref][i]
1781            if debug:
1782                print(type(x), x)
1783            if x.action != Action.REPORT:
1784                assert x.account is not None
1785                if x.action != Action.EXCHANGE:
1786                    assert self.account_exists(x.account)
1787            match x.action:
1788                case Action.CREATE:
1789                    if debug:
1790                        print('account', self.__vault.account[x.account])
1791                    assert len(self.__vault.account[x.account].box) == 0
1792                    assert len(self.__vault.account[x.account].log) == 0
1793                    assert self.__vault.account[x.account].balance == 0
1794                    assert self.__vault.account[x.account].count == 0
1795                    assert self.__vault.account[x.account].name == ''
1796                    if dry:
1797                        continue
1798                    del self.__vault.account[x.account]
1799
1800                case Action.NAME:
1801                    assert x.value is not None
1802                    if dry:
1803                        continue
1804                    self.__vault.account[x.account].name = x.value
1805
1806                case Action.TRACK:
1807                    assert x.value is not None
1808                    assert x.ref is not None
1809                    if dry:
1810                        continue
1811                    self.__vault.account[x.account].balance -= x.value
1812                    self.__vault.account[x.account].count -= 1
1813                    del self.__vault.account[x.account].box[x.ref]
1814
1815                case Action.LOG:
1816                    assert x.ref in self.__vault.account[x.account].log
1817                    assert x.value is not None
1818                    if dry:
1819                        continue
1820                    if sub_positive_log_negative == -x.value:
1821                        self.__vault.account[x.account].count -= 1
1822                        sub_positive_log_negative = 0
1823                    box_ref = self.__vault.account[x.account].log[x.ref].ref
1824                    if not box_ref is None:
1825                        assert self.box_exists(x.account, box_ref)
1826                        box_value = self.__vault.account[x.account].log[x.ref].value
1827                        assert box_value < 0
1828
1829                        try:
1830                            self.__vault.account[x.account].box[box_ref].rest += -box_value
1831                        except TypeError:
1832                            self.__vault.account[x.account].box[box_ref].rest += decimal.Decimal(-box_value)
1833
1834                        try:
1835                            self.__vault.account[x.account].balance += -box_value
1836                        except TypeError:
1837                            self.__vault.account[x.account].balance += decimal.Decimal(-box_value)
1838
1839                        self.__vault.account[x.account].count -= 1
1840                    del self.__vault.account[x.account].log[x.ref]
1841
1842                case Action.SUBTRACT:
1843                    assert x.ref in self.__vault.account[x.account].box
1844                    assert x.value is not None
1845                    if dry:
1846                        continue
1847                    self.__vault.account[x.account].box[x.ref].rest += x.value
1848                    self.__vault.account[x.account].balance += x.value
1849                    sub_positive_log_negative = x.value
1850
1851                case Action.ADD_FILE:
1852                    assert x.ref in self.__vault.account[x.account].log
1853                    assert x.file is not None
1854                    assert dry or x.file in self.__vault.account[x.account].log[x.ref].file
1855                    if dry:
1856                        continue
1857                    del self.__vault.account[x.account].log[x.ref].file[x.file]
1858
1859                case Action.REMOVE_FILE:
1860                    assert x.ref in self.__vault.account[x.account].log
1861                    assert x.file is not None
1862                    assert x.value is not None
1863                    if dry:
1864                        continue
1865                    self.__vault.account[x.account].log[x.ref].file[x.file] = x.value
1866
1867                case Action.BOX_TRANSFER:
1868                    assert x.ref in self.__vault.account[x.account].box
1869                    assert x.value is not None
1870                    if dry:
1871                        continue
1872                    self.__vault.account[x.account].box[x.ref].rest -= x.value
1873
1874                case Action.EXCHANGE:
1875                    assert x.account in self.__vault.exchange
1876                    assert x.ref in self.__vault.exchange[x.account]
1877                    if dry:
1878                        continue
1879                    del self.__vault.exchange[x.account][x.ref]
1880
1881                case Action.REPORT:
1882                    assert x.ref in self.__vault.report
1883                    if dry:
1884                        continue
1885                    del self.__vault.report[x.ref]
1886
1887                case Action.ZAKAT:
1888                    assert x.ref in self.__vault.account[x.account].box
1889                    assert x.key is not None
1890                    assert hasattr(self.__vault.account[x.account].box[x.ref].zakat, x.key)
1891                    if dry:
1892                        continue
1893                    match x.math:
1894                        case MathOperation.ADDITION:
1895                            setattr(
1896                                self.__vault.account[x.account].box[x.ref].zakat,
1897                                x.key,
1898                                getattr(self.__vault.account[x.account].box[x.ref].zakat, x.key) - x.value,
1899                            )
1900                        case MathOperation.EQUAL:
1901                            setattr(
1902                                self.__vault.account[x.account].box[x.ref].zakat,
1903                                x.key,
1904                                x.value,
1905                            )
1906                        case MathOperation.SUBTRACTION:
1907                            setattr(
1908                                self.__vault.account[x.account].box[x.ref],
1909                                x.key,
1910                                getattr(self.__vault.account[x.account].box[x.ref], x.key) + x.value,
1911                            )
1912
1913        if not dry:
1914            del self.__vault.history[ref]
1915        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:
1917    def vault(self) -> dict:
1918        """
1919        Returns a copy of the internal vault dictionary.
1920
1921        This method is used to retrieve the current state of the ZakatTracker object.
1922        It provides a snapshot of the internal data structure, allowing for further
1923        processing or analysis.
1924
1925        Parameters:
1926        None
1927
1928        Returns:
1929        - dict: A copy of the internal vault dictionary.
1930        """
1931        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:
1933    @staticmethod
1934    def stats_init() -> FileStats:
1935        """
1936        Initialize and return the initial file statistics.
1937
1938        Returns:
1939        - FileStats: A :class:`FileStats` instance with initial values
1940            of 0 bytes for both RAM and database.
1941        """
1942        return FileStats(
1943            database=SizeInfo(0, '0'),
1944            ram=SizeInfo(0, '0'),
1945        )

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:
1947    def stats(self, ignore_ram: bool = True) -> FileStats:
1948        """
1949        Calculates and returns statistics about the object's data storage.
1950
1951        This method determines the size of the database file on disk and the
1952        size of the data currently held in RAM (likely within a dictionary).
1953        Both sizes are reported in bytes and in a human-readable format
1954        (e.g., KB, MB).
1955
1956        Parameters:
1957        - ignore_ram (bool, optional): Whether to ignore the RAM size. Default is True
1958
1959        Returns:
1960        - FileStats: A dataclass containing the following statistics:
1961
1962            * 'database': A tuple with two elements:
1963                - The database file size in bytes (float).
1964                - The database file size in human-readable format (str).
1965            * 'ram': A tuple with two elements:
1966                - The RAM usage (dictionary size) in bytes (float).
1967                - The RAM usage in human-readable format (str).
1968
1969        Example:
1970        ```bash
1971        >>> x = ZakatTracker()
1972        >>> stats = x.stats()
1973        >>> print(stats.database)
1974        SizeInfo(bytes=256000, human_readable='250.0 KB')
1975        >>> print(stats.ram)
1976        SizeInfo(bytes=12345, human_readable='12.1 KB')
1977        ```
1978        """
1979        ram_size = 0.0 if ignore_ram else self.get_dict_size(self.vault())
1980        file_size = os.path.getsize(self.path())
1981        return FileStats(
1982            database=SizeInfo(file_size, self.human_readable_size(file_size)),
1983            ram=SizeInfo(ram_size, self.human_readable_size(ram_size)),
1984        )

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

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:
2310    def log_exists(self, account: AccountID, ref: Timestamp) -> bool:
2311        """
2312        Checks if a specific transaction log entry exists for a given account.
2313
2314        Parameters:
2315        - account (AccountID): The account reference associated with the transaction log.
2316        - ref (Timestamp): The reference to the transaction log entry.
2317
2318        Returns:
2319        - bool: True if the transaction log entry exists, False otherwise.
2320        """
2321        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:
2374    def exchange(self, account: AccountID, created_time_ns: Optional[Timestamp] = None,
2375                 rate: Optional[float] = None, description: Optional[str] = None, debug: bool = False) -> Exchange:
2376        """
2377        This method is used to record or retrieve exchange rates for a specific account.
2378
2379        Parameters:
2380        - account (AccountID): The account reference for which the exchange rate is being recorded or retrieved.
2381        - created_time_ns (Timestamp, optional): The timestamp of the exchange rate. If not provided, the current timestamp will be used.
2382        - rate (float, optional): The exchange rate to be recorded. If not provided, the method will retrieve the latest exchange rate.
2383        - description (str, optional): A description of the exchange rate.
2384        - debug (bool, optional): Whether to print debug information. Default is False.
2385
2386        Returns:
2387        - Exchange: A dictionary containing the latest exchange rate and its description. If no exchange rate is found,
2388        it returns a dictionary with default values for the rate and description.
2389
2390        Raises:
2391        - ValueError: The created should be greater than zero.
2392        """
2393        if debug:
2394            print('exchange', f'debug={debug}')
2395        account = AccountID(account)
2396        if created_time_ns is None:
2397            created_time_ns = Time.time()
2398        if created_time_ns <= 0:
2399            raise ValueError('The created should be greater than zero.')
2400        if rate is not None:
2401            if rate <= 0:
2402                return Exchange()
2403            if account not in self.__vault.exchange:
2404                self.__vault.exchange[account] = {}
2405            if len(self.__vault.exchange[account]) == 0 and rate <= 1:
2406                return Exchange(time=created_time_ns, rate=1)
2407            no_lock = self.nolock()
2408            lock = self.__lock()
2409            self.__vault.exchange[account][created_time_ns] = Exchange(rate=rate, description=description)
2410            self.__step(Action.EXCHANGE, account, ref=created_time_ns, value=rate)
2411            if no_lock:
2412                assert lock is not None
2413                self.free(lock)
2414            if debug:
2415                print('exchange-created-1',
2416                      f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}')
2417
2418        if account in self.__vault.exchange:
2419            valid_rates = [(ts, r) for ts, r in self.__vault.exchange[account].items() if ts <= created_time_ns]
2420            if valid_rates:
2421                latest_rate = max(valid_rates, key=lambda x: x[0])
2422                if debug:
2423                    print('exchange-read-1',
2424                          f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}',
2425                          'latest_rate', latest_rate)
2426                result = latest_rate[1]
2427                result.time = latest_rate[0]
2428                return result  # إرجاع قاموس يحتوي على المعدل والوصف
2429        if debug:
2430            print('exchange-read-0', f'account: {account}, created: {created_time_ns}, rate:{rate}, description:{description}')
2431        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:
2433    @staticmethod
2434    def exchange_calc(x: float, x_rate: float, y_rate: float) -> float:
2435        """
2436        This function calculates the exchanged amount of a currency.
2437
2438        Parameters:
2439        - x (float): The original amount of the currency.
2440        - x_rate (float): The exchange rate of the original currency.
2441        - y_rate (float): The exchange rate of the target currency.
2442
2443        Returns:
2444        - float: The exchanged amount of the target currency.
2445        """
2446        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]]:
2448    def exchanges(self) -> dict[AccountID, dict[Timestamp, Exchange]]:
2449        """
2450        Retrieve the recorded exchange rates for all accounts.
2451
2452        Parameters:
2453        None
2454
2455        Returns:
2456        - dict[AccountID, dict[Timestamp, Exchange]]: A dictionary containing all recorded exchange rates.
2457        The keys are account references or numbers, and the values are dictionaries containing the exchange rates.
2458        Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
2459        """
2460        return self.__vault.exchange.copy()

Retrieve the recorded exchange rates for all accounts.

Parameters: None

Returns:

  • dict[AccountID, dict[Timestamp, Exchange]]: A dictionary containing all recorded exchange rates. The keys are account references or numbers, and the values are dictionaries containing the exchange rates. Each exchange rate dictionary has timestamps as keys and exchange rate details as values.
def accounts( self) -> dict[AccountID, AccountDetails]:
2462    def accounts(self) -> dict[AccountID, AccountDetails]:
2463        """
2464        Returns a dictionary containing account references as keys and their respective account details as values.
2465
2466        Parameters:
2467        None
2468
2469        Returns:
2470        - dict[AccountID, AccountDetails]: A dictionary where keys are account references and values are their respective details.
2471        """
2472        return {
2473            account_id: AccountDetails(
2474                account_id=account_id,
2475                account_name=self.__vault.account[account_id].name,
2476                balance=self.__vault.account[account_id].balance,
2477            )
2478            for account_id in self.__vault.account
2479        }

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

Parameters: None

Returns:

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

Check or set the hide status of a specific account.

Parameters:

  • account (AccountID): The account reference.
  • status (bool, optional): The new hide status. If not provided, the function will return the current status.

Returns:

  • bool: The current or updated hide status of the account.

Raises: None

Example:

>>> tracker = ZakatTracker()
>>> ref = tracker.track(51, 'desc', 'account1')
>>> tracker.hide('account1')  # Set the hide status of 'account1' to True
False
>>> tracker.hide('account1', True)  # Set the hide status of 'account1' to True
True
>>> tracker.hide('account1')  # Get the hide status of 'account1' by default
True
>>> tracker.hide('account1', False)
False
def account( self, name: str, exact: bool = True) -> Optional[AccountDetails]:
2771    def account(self, name: str, exact: bool = True) -> Optional[AccountDetails]:
2772        """
2773        Retrieves an AccountDetails object for the first account matching the given name.
2774
2775        This method searches for accounts with names that contain the provided 'name'
2776        (case-insensitive substring matching). If a match is found, it returns an
2777        AccountDetails object containing the account's ID, name and balance. If no matching
2778        account is found, it returns None.
2779
2780        Parameters:
2781        - name: The name (or partial name) of the account to retrieve.
2782        - exact: If True, performs a case-insensitive exact match.
2783                 If False, performs a case-insensitive substring search.
2784                 Defaults to True.
2785
2786        Returns:
2787        - AccountDetails: An AccountDetails object representing the found account, or None if no
2788            matching account exists.
2789        """
2790        for account_name, account_id in self.names(name).items():
2791            if not exact or account_name.lower() == name.lower():
2792                return AccountDetails(
2793                    account_id=account_id,
2794                    account_name=account_name,
2795                    balance=self.__vault.account[account_id].balance,
2796                )
2797        return None

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

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

Parameters:

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

Returns:

  • AccountDetails: An AccountDetails object representing the found account, or None if no matching account exists.
def create_account(self, name: str) -> AccountID:
2799    def create_account(self, name: str) -> AccountID:
2800        """
2801        Creates a new account with the given name and returns its unique ID.
2802
2803        This method:
2804        1. Checks if an account with the same name (case-insensitive) already exists.
2805        2. Generates a unique `AccountID` based on the current time.
2806        3. Tracks the account creation internally.
2807        4. Sets the account's name.
2808        5. Verifies that the name was set correctly.
2809    
2810        Parameters:
2811        - name: The name of the new account.
2812    
2813        Returns:
2814        - AccountID: The unique `AccountID` of the newly created account.
2815    
2816        Raises:
2817        - AssertionError: Empty account name is forbidden.
2818        - AssertionError: Account name in number is forbidden.
2819        - AssertionError: If an account with the same name already exists (case-insensitive).
2820        - AssertionError: If the provided name does not match the name set for the account.
2821        """
2822        assert name.strip(), 'empty account name is forbidden'
2823        assert not name.isdigit() and not name.isdecimal() and not name.isnumeric() and not is_number(name), f'Account name({name}) in number is forbidden'
2824        account_ref = self.account(name, exact=True)
2825        # check if account not exists
2826        assert account_ref is None, f'account name({name}) already used'
2827        # create new account
2828        account_id = AccountID(Time.time())
2829        self.__track(0, '', account_id)
2830        new_name = self.name(
2831            account=account_id,
2832            new_name=name,
2833        )
2834        assert name == new_name
2835        return account_id

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

This method:

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

Parameters:

  • name: The name of the new account.

Returns:

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

Raises:

  • AssertionError: Empty account name is forbidden.
  • AssertionError: Account name in number is forbidden.
  • AssertionError: If an account with the same name already exists (case-insensitive).
  • AssertionError: If the provided name does not match the name set for the account.
def names(self, keyword: str = '') -> dict[str, AccountID]:
2837    def names(self, keyword: str = '') -> dict[str, AccountID]:
2838        """
2839        Retrieves a dictionary of account IDs and names, optionally filtered by a keyword.
2840
2841        Parameters:
2842        - keyword: An optional string to filter account names. If provided, only accounts whose
2843            names contain the keyword (case-insensitive) will be included in the result.
2844            Defaults to an empty string, which returns all accounts.
2845
2846        Returns:
2847        - A dictionary where keys are account names and values are AccountIDs. The dictionary
2848            contains only accounts that match the provided keyword (if any).
2849        """
2850        return {
2851            account.name: account_id
2852            for account_id, account in self.__vault.account.items()
2853            if keyword.lower() in account.name.lower()
2854        }

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:
2856    def name(self, account: AccountID, new_name: Optional[str] = None) -> str:
2857        """
2858        Retrieves or sets the name of an account.
2859
2860        Parameters:
2861        - account: The AccountID of the account.
2862        - new_name: The new name to set for the account. If None, the current name is retrieved.
2863
2864        Returns:
2865        - The current name of the account if `new_name` is None, or the `new_name` if it is set.
2866
2867        Note: Returns an empty string if the account does not exist.
2868        """
2869        if self.account_exists(account):
2870            if new_name is None:
2871                return self.__vault.account[account].name
2872            assert new_name != ''
2873            no_lock = self.nolock()
2874            lock = self.__lock()
2875            self.__step(Action.NAME, account, value=self.__vault.account[account].name)
2876            self.__vault.account[account].name = new_name
2877            if no_lock:
2878                    assert lock is not None
2879                    self.free(lock)
2880            return new_name
2881        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:
2883    def zakatable(self, account: AccountID, status: Optional[bool] = None) -> bool:
2884        """
2885        Check or set the zakatable status of a specific account.
2886
2887        Parameters:
2888        - account (AccountID): The account reference.
2889        - status (bool, optional): The new zakatable status. If not provided, the function will return the current status.
2890
2891        Returns:
2892        - bool: The current or updated zakatable status of the account.
2893
2894        Raises:
2895        None
2896
2897        Example:
2898        ```bash
2899        >>> tracker = ZakatTracker()
2900        >>> ref = tracker.track(51, 'desc', 'account1')
2901        >>> tracker.zakatable('account1')  # Set the zakatable status of 'account1' to True
2902        True
2903        >>> tracker.zakatable('account1', True)  # Set the zakatable status of 'account1' to True
2904        True
2905        >>> tracker.zakatable('account1')  # Get the zakatable status of 'account1' by default
2906        True
2907        >>> tracker.zakatable('account1', False)
2908        False
2909        ```
2910        """
2911        if self.account_exists(account):
2912            if status is None:
2913                return self.__vault.account[account].zakatable
2914            self.__vault.account[account].zakatable = status
2915            return status
2916        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:
2918    def subtract(self, unscaled_value: float | int | decimal.Decimal, desc: str = '', account: AccountID = AccountID('1'),
2919            created_time_ns: Optional[Timestamp] = None,
2920            debug: bool = False) \
2921            -> SubtractReport:
2922        """
2923        Subtracts a specified value from an account's balance, if the amount to subtract is greater than the account's balance,
2924        the remaining amount will be transferred to a new transaction with a negative value.
2925
2926        Parameters:
2927        - unscaled_value (float | int | decimal.Decimal): The amount to be subtracted.
2928        - desc (str, optional): A description for the transaction. Defaults to an empty string.
2929        - account (AccountID, optional): The account reference from which the value will be subtracted. Defaults to '1'.
2930        - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD).
2931                                           If not provided, the current timestamp will be used.
2932        - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False.
2933
2934        Returns:
2935        - SubtractReport: A class containing the timestamp of the transaction and a list of tuples representing the age of each transaction.
2936
2937        Raises:
2938        - ValueError: The unscaled_value should be greater than zero.
2939        - ValueError: The created_time_ns should be greater than zero.
2940        - ValueError: The box transaction happened again in the same nanosecond time.
2941        - ValueError: The log transaction happened again in the same nanosecond time.
2942        """
2943        if debug:
2944            print('sub', f'debug={debug}')
2945        account = AccountID(account)
2946        if unscaled_value <= 0:
2947            raise ValueError('The unscaled_value should be greater than zero.')
2948        if created_time_ns is None:
2949            created_time_ns = Time.time()
2950        if created_time_ns <= 0:
2951            raise ValueError('The created should be greater than zero.')
2952        no_lock = self.nolock()
2953        lock = self.__lock()
2954        self.__track(0, '', account)
2955        value = self.scale(unscaled_value)
2956        self.__log(value=-value, desc=desc, account=account, created_time_ns=created_time_ns, ref=None, debug=debug)
2957        ids = sorted(self.__vault.account[account].box.keys())
2958        limit = len(ids) + 1
2959        target = value
2960        if debug:
2961            print('ids', ids)
2962        ages = SubtractAges()
2963        for i in range(-1, -limit, -1):
2964            if target == 0:
2965                break
2966            j = ids[i]
2967            if debug:
2968                print('i', i, 'j', j)
2969            rest = self.__vault.account[account].box[j].rest
2970            if rest >= target:
2971                self.__vault.account[account].box[j].rest -= target
2972                self.__step(Action.SUBTRACT, account, ref=j, value=target)
2973                ages.append(SubtractAge(box_ref=j, total=target))
2974                target = 0
2975                break
2976            elif target > rest > 0:
2977                chunk = rest
2978                target -= chunk
2979                self.__vault.account[account].box[j].rest = 0
2980                self.__step(Action.SUBTRACT, account, ref=j, value=chunk)
2981                ages.append(SubtractAge(box_ref=j, total=chunk))
2982        if target > 0:
2983            self.__track(
2984                unscaled_value=self.unscale(-target),
2985                desc=desc,
2986                account=account,
2987                logging=False,
2988                created_time_ns=created_time_ns,
2989            )
2990            ages.append(SubtractAge(box_ref=created_time_ns, total=target))
2991        if no_lock:
2992            assert lock is not None
2993            self.free(lock)
2994        return SubtractReport(
2995            log_ref=created_time_ns,
2996            ages=ages,
2997        )

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]:
2999    def transfer(self, unscaled_amount: float | int | decimal.Decimal, from_account: AccountID, to_account: AccountID, desc: str = '',
3000                 created_time_ns: Optional[Timestamp] = None,
3001                 debug: bool = False) -> Optional[TransferReport]:
3002        """
3003        Transfers a specified value from one account to another.
3004
3005        Parameters:
3006        - unscaled_amount (float | int | decimal.Decimal): The amount to be transferred.
3007        - from_account (AccountID): The account reference from which the value will be transferred.
3008        - to_account (AccountID): The account reference to which the value will be transferred.
3009        - desc (str, optional): A description for the transaction. Defaults to an empty string.
3010        - created_time_ns (Timestamp, optional): The timestamp of the transaction in nanoseconds since epoch(1AD). If not provided, the current timestamp will be used.
3011        - debug (bool, optional): A flag indicating whether to print debug information. Defaults to False.
3012
3013        Returns:
3014        - Optional[TransferReport]: A class of timestamps corresponding to the transactions made during the transfer.
3015
3016        Raises:
3017        - ValueError: Transfer to the same account is forbidden.
3018        - ValueError: The created_time_ns should be greater than zero.
3019        - ValueError: The box transaction happened again in the same nanosecond time.
3020        - ValueError: The log transaction happened again in the same nanosecond time.
3021        """
3022        if debug:
3023            print('transfer', f'debug={debug}')
3024        from_account = AccountID(from_account)
3025        to_account = AccountID(to_account)
3026        if from_account == to_account:
3027            raise ValueError(f'Transfer to the same account is forbidden. {to_account}')
3028        if unscaled_amount <= 0:
3029            return None
3030        if created_time_ns is None:
3031            created_time_ns = Time.time()
3032        if created_time_ns <= 0:
3033            raise ValueError('The created should be greater than zero.')
3034        no_lock = self.nolock()
3035        lock = self.__lock()
3036        subtract_report = self.subtract(unscaled_amount, desc, from_account, created_time_ns, debug=debug)
3037        source_exchange = self.exchange(from_account, created_time_ns)
3038        target_exchange = self.exchange(to_account, created_time_ns)
3039
3040        if debug:
3041            print('ages', subtract_report.ages)
3042
3043        transfer_report = TransferReport()
3044        for subtract in subtract_report.ages:
3045            times = TransferTimes()
3046            age = subtract.box_ref
3047            value = subtract.total
3048            assert source_exchange.rate is not None
3049            assert target_exchange.rate is not None
3050            target_amount = int(self.exchange_calc(value, source_exchange.rate, target_exchange.rate))
3051            if debug:
3052                print('target_amount', target_amount)
3053            # Perform the transfer
3054            if self.box_exists(to_account, age):
3055                if debug:
3056                    print('box_exists', age)
3057                capital = self.__vault.account[to_account].box[age].capital
3058                rest = self.__vault.account[to_account].box[age].rest
3059                if debug:
3060                    print(
3061                        f'Transfer(loop) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).')
3062                selected_age = age
3063                if rest + target_amount > capital:
3064                    self.__vault.account[to_account].box[age].capital += target_amount
3065                    selected_age = Time.time()
3066                self.__vault.account[to_account].box[age].rest += target_amount
3067                self.__step(Action.BOX_TRANSFER, to_account, ref=selected_age, value=target_amount)
3068                y = self.__log(value=target_amount, desc=f'TRANSFER {from_account} -> {to_account}', account=to_account,
3069                              created_time_ns=None, ref=None, debug=debug)
3070                times.append(TransferTime(box_ref=age, log_ref=y))
3071                continue
3072            if debug:
3073                print(
3074                    f'Transfer(func) {value} from `{from_account}` to `{to_account}` (equivalent to {target_amount} `{to_account}`).')
3075            box_ref = self.__track(
3076                unscaled_value=self.unscale(int(target_amount)),
3077                desc=desc,
3078                account=to_account,
3079                logging=True,
3080                created_time_ns=age,
3081                debug=debug,
3082            )
3083            transfer_report.append(TransferRecord(
3084                box_ref=box_ref,
3085                times=times,
3086            ))
3087        if no_lock:
3088            assert lock is not None
3089            self.free(lock)
3090        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:
3092    def check(self,
3093              silver_gram_price: float,
3094              unscaled_nisab: Optional[float | int | decimal.Decimal] = None,
3095              debug: bool = False,
3096              created_time_ns: Optional[Timestamp] = None,
3097              cycle: Optional[float] = None) -> ZakatReport:
3098        """
3099        Check the eligibility for Zakat based on the given parameters.
3100
3101        Parameters:
3102        - silver_gram_price (float): The price of a gram of silver.
3103        - unscaled_nisab (float | int | decimal.Decimal, optional): The minimum amount of wealth required for Zakat.
3104                        If not provided, it will be calculated based on the silver_gram_price.
3105        - debug (bool, optional): Flag to enable debug mode.
3106        - created_time_ns (Timestamp, optional): The current timestamp. If not provided, it will be calculated using Time.time().
3107        - cycle (float, optional): The time cycle for Zakat. If not provided, it will be calculated using ZakatTracker.TimeCycle().
3108
3109        Returns:
3110        - ZakatReport: A tuple containing a boolean indicating the eligibility for Zakat,
3111            a list of brief statistics, and a dictionary containing the Zakat plan.
3112        """
3113        if debug:
3114            print('check', f'debug={debug}')
3115        before_parameters = {
3116            "silver_gram_price": silver_gram_price,
3117            "unscaled_nisab": unscaled_nisab,
3118            "debug": debug,
3119            "created_time_ns": created_time_ns,
3120            "cycle": cycle,
3121        }
3122        if created_time_ns is None:
3123            created_time_ns = Time.time()
3124        if cycle is None:
3125            cycle = ZakatTracker.TimeCycle()
3126        if unscaled_nisab is None:
3127            unscaled_nisab = ZakatTracker.Nisab(silver_gram_price)
3128        nisab = self.scale(unscaled_nisab)
3129        plan: dict[AccountID, list[BoxPlan]] = {}
3130        summary = ZakatSummary()
3131        below_nisab = 0
3132        valid = False
3133        after_parameters = {
3134            "silver_gram_price": silver_gram_price,
3135            "unscaled_nisab": unscaled_nisab,
3136            "debug": debug,
3137            "created_time_ns": created_time_ns,
3138            "cycle": cycle,
3139        }
3140        if debug:
3141            print('exchanges', self.exchanges())
3142        for x in self.__vault.account:
3143            if not self.zakatable(x):
3144                continue
3145            _box = self.__vault.account[x].box
3146            _log = self.__vault.account[x].log
3147            limit = len(_box) + 1
3148            ids = sorted(self.__vault.account[x].box.keys())
3149            for i in range(-1, -limit, -1):
3150                j = ids[i]
3151                rest = float(_box[j].rest)
3152                if rest <= 0:
3153                    continue
3154                exchange = self.exchange(x, created_time_ns=Time.time())
3155                assert exchange.rate is not None
3156                rest = ZakatTracker.exchange_calc(rest, float(exchange.rate), 1)
3157                summary.num_wealth_items += 1
3158                summary.total_wealth += rest
3159                epoch = (created_time_ns - j) / cycle
3160                if debug:
3161                    print(f'Epoch: {epoch}', _box[j])
3162                if _box[j].zakat.last > 0:
3163                    epoch = (created_time_ns - _box[j].zakat.last) / cycle
3164                if debug:
3165                    print(f'Epoch: {epoch}')
3166                epoch = math.floor(epoch)
3167                if debug:
3168                    print(f'Epoch: {epoch}', type(epoch), epoch == 0, 1 - epoch, epoch)
3169                if epoch == 0:
3170                    continue
3171                if debug:
3172                    print('Epoch - PASSED')
3173                summary.num_zakatable_items += 1
3174                summary.total_zakatable_amount += rest
3175                is_nisab = rest >= nisab
3176                total = 0
3177                if is_nisab:
3178                    for _ in range(epoch):
3179                        total += ZakatTracker.ZakatCut(float(rest) - float(total))
3180                    valid = total > 0
3181                elif rest > 0:
3182                    below_nisab += rest
3183                    total = ZakatTracker.ZakatCut(float(rest))
3184                if total > 0:
3185                    if x not in plan:
3186                        plan[x] = []
3187                    summary.total_zakat_due += total
3188                    plan[x].append(BoxPlan(
3189                        below_nisab=not is_nisab,
3190                        total=total,
3191                        count=epoch,
3192                        ref=j,
3193                        box=_box[j],
3194                        log=_log[j],
3195                        exchange=exchange,
3196                    ))
3197        valid = valid or below_nisab >= nisab
3198        if debug:
3199            print(f'below_nisab({below_nisab}) >= nisab({nisab})')
3200        report = ZakatReport(
3201            created=Time.time(),
3202            valid=valid,
3203            summary=summary,
3204            plan=plan,
3205            parameters={
3206                'before': before_parameters,
3207                'after': after_parameters,
3208            },
3209        )
3210        self.__vault.cache.zakat = report
3211        return report

Check the eligibility for Zakat based on the given parameters.

Parameters:

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

Returns:

  • ZakatReport: A tuple containing a boolean indicating the eligibility for Zakat, a list of brief statistics, and a dictionary containing the Zakat plan.
def build_payment_parts( self, scaled_demand: int, positive_only: bool = True) -> PaymentParts:
3213    def build_payment_parts(self, scaled_demand: int, positive_only: bool = True) -> PaymentParts:
3214        """
3215        Build payment parts for the Zakat distribution.
3216
3217        Parameters:
3218        - scaled_demand (int): The total demand for payment in local currency.
3219        - positive_only (bool, optional): If True, only consider accounts with positive balance. Default is True.
3220
3221        Returns:
3222        - PaymentParts: A dictionary containing the payment parts for each account. The dictionary has the following structure:
3223        {
3224            'account': {
3225                'account_id': {'balance': float, 'rate': float, 'part': float},
3226                ...
3227            },
3228            'exceed': bool,
3229            'demand': int,
3230            'total': float,
3231        }
3232        """
3233        total = 0.0
3234        parts = PaymentParts(
3235            account={},
3236            exceed=False,
3237            demand=int(round(scaled_demand)),
3238            total=0,
3239        )
3240        for x, y in self.accounts().items():
3241            if positive_only and y.balance <= 0:
3242                continue
3243            total += float(y.balance)
3244            exchange = self.exchange(x)
3245            parts.account[x] = AccountPaymentPart(balance=y.balance, rate=exchange.rate, part=0)
3246        parts.total = total
3247        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:
3249    @staticmethod
3250    def check_payment_parts(parts: PaymentParts, debug: bool = False) -> int:
3251        """
3252        Checks the validity of payment parts.
3253
3254        Parameters:
3255        - parts (dict[str, PaymentParts): A dictionary containing payment parts information.
3256        - debug (bool, optional): Flag to enable debug mode.
3257
3258        Returns:
3259        - int: Returns 0 if the payment parts are valid, otherwise returns the error code.
3260
3261        Error Codes:
3262        1: 'demand', 'account', 'total', or 'exceed' key is missing in parts.
3263        2: 'balance', 'rate' or 'part' key is missing in parts['account'][x].
3264        3: 'part' value in parts['account'][x] is less than 0.
3265        4: If 'exceed' is False, 'balance' value in parts['account'][x] is less than or equal to 0.
3266        5: If 'exceed' is False, 'part' value in parts['account'][x] is greater than 'balance' value.
3267        6: The sum of 'part' values in parts['account'] does not match with 'demand' value.
3268        """
3269        if debug:
3270            print('check_payment_parts', f'debug={debug}')
3271        # for i in ['demand', 'account', 'total', 'exceed']:
3272        #     if i not in parts:
3273        #         return 1
3274        exceed = parts.exceed
3275        # for j in ['balance', 'rate', 'part']:
3276        #     if j not in parts.account[x]:
3277        #         return 2
3278        for x in parts.account:
3279            if parts.account[x].part < 0:
3280                return 3
3281            if not exceed and parts.account[x].balance <= 0:
3282                return 4
3283        demand = parts.demand
3284        z = 0.0
3285        for _, y in parts.account.items():
3286            if not exceed and y.part > y.balance:
3287                return 5
3288            z += ZakatTracker.exchange_calc(y.part, y.rate, 1.0)
3289        z = round(z, 2)
3290        demand = round(demand, 2)
3291        if debug:
3292            print('check_payment_parts', f'z = {z}, demand = {demand}')
3293            print('check_payment_parts', type(z), type(demand))
3294            print('check_payment_parts', z != demand)
3295            print('check_payment_parts', str(z) != str(demand))
3296        if z != demand and str(z) != str(demand):
3297            return 6
3298        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:
3300    def zakat(self, report: ZakatReport,
3301        parts: Optional[PaymentParts] = None, debug: bool = False) -> bool:
3302        """
3303        Perform Zakat calculation based on the given report and optional parts.
3304
3305        Parameters:
3306        - report (ZakatReport): A dataclass containing the validity of the report, the report data, and the zakat plan.
3307        - parts (PaymentParts, optional): A dictionary containing the payment parts for the zakat.
3308        - debug (bool, optional): A flag indicating whether to print debug information.
3309
3310        Returns:
3311        - bool: True if the zakat calculation is successful, False otherwise.
3312
3313        Raises:
3314        - AssertionError: Bad Zakat report, call `check` first then call `zakat`.
3315        """
3316        if debug:
3317            print('zakat', f'debug={debug}')
3318        if not report.valid:
3319            return report.valid
3320        assert report.plan
3321        parts_exist = parts is not None
3322        if parts_exist:
3323            if self.check_payment_parts(parts, debug=debug) != 0:
3324                return False
3325        if debug:
3326            print('######### zakat #######')
3327            print('parts_exist', parts_exist)
3328        assert report == self.__vault.cache.zakat, "Bad Zakat report, call `check` first then call `zakat`"
3329        no_lock = self.nolock()
3330        lock = self.__lock()
3331        report_time = Time.time()
3332        self.__vault.report[report_time] = report
3333        self.__step(Action.REPORT, ref=report_time)
3334        created_time_ns = Time.time()
3335        for x in report.plan:
3336            target_exchange = self.exchange(x)
3337            if debug:
3338                print(report.plan[x])
3339                print('-------------')
3340                print(self.__vault.account[x].box)
3341            if debug:
3342                print('plan[x]', report.plan[x])
3343            for plan in report.plan[x]:
3344                j = plan.ref
3345                if debug:
3346                    print('j', j)
3347                assert j
3348                self.__step(Action.ZAKAT, account=x, ref=j, value=self.__vault.account[x].box[j].zakat.last,
3349                           key='last',
3350                           math_operation=MathOperation.EQUAL)
3351                self.__vault.account[x].box[j].zakat.last = created_time_ns
3352                assert target_exchange.rate is not None
3353                amount = ZakatTracker.exchange_calc(float(plan.total), 1, float(target_exchange.rate))
3354                self.__vault.account[x].box[j].zakat.total += amount
3355                self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='total',
3356                           math_operation=MathOperation.ADDITION)
3357                self.__vault.account[x].box[j].zakat.count += plan.count
3358                self.__step(Action.ZAKAT, account=x, ref=j, value=plan.count, key='count',
3359                           math_operation=MathOperation.ADDITION)
3360                if not parts_exist:
3361                    try:
3362                        self.__vault.account[x].box[j].rest -= amount
3363                    except TypeError:
3364                        self.__vault.account[x].box[j].rest -= decimal.Decimal(amount)
3365                    # self.__step(Action.ZAKAT, account=x, ref=j, value=amount, key='rest',
3366                    #            math_operation=MathOperation.SUBTRACTION)
3367                    self.__log(-float(amount), desc='zakat-زكاة', account=x, created_time_ns=None, ref=j, debug=debug)
3368        if parts_exist:
3369            for account, part in parts.account.items():
3370                if part.part == 0:
3371                    continue
3372                if debug:
3373                    print('zakat-part', account, part.rate)
3374                target_exchange = self.exchange(account)
3375                assert target_exchange.rate is not None
3376                amount = ZakatTracker.exchange_calc(part.part, part.rate, target_exchange.rate)
3377                unscaled_amount = self.unscale(int(amount))
3378                if unscaled_amount <= 0:
3379                    if debug:
3380                        print(f"The amount({unscaled_amount:.20f}) it was {amount:.20f} should be greater tha zero.")
3381                    continue
3382                self.subtract(
3383                    unscaled_value=unscaled_amount,
3384                    desc='zakat-part-دفعة-زكاة',
3385                    account=account,
3386                    debug=debug,
3387                )
3388        if no_lock:
3389            assert lock is not None
3390            self.free(lock)
3391        self.__vault.cache.zakat = None
3392        return True

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

Parameters:

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

Returns:

  • bool: True if the zakat calculation is successful, False otherwise.

Raises:

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

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:
3668    def import_csv(self, path: str = 'file.csv', scale_decimal_places: int = 0, debug: bool = False) -> ImportReport:
3669        """
3670        The function reads the CSV file, checks for duplicate transactions and tries it's best to creates the transactions history accordingly in the system.
3671
3672        Parameters:
3673        - path (str, optional): The path to the CSV file. Default is 'file.csv'.
3674        - scale_decimal_places (int, optional): The number of decimal places to scale the value. Default is 0.
3675        - debug (bool, optional): A flag indicating whether to print debug information.
3676
3677        Returns:
3678        - ImportReport: A dataclass containing the number of transactions created, the number of transactions found in the cache,
3679                and a dictionary of bad transactions.
3680
3681        Notes:
3682        * Currency Pair Assumption: This function assumes that the exchange rates stored for each account
3683                                    are appropriate for the currency pairs involved in the conversions.
3684        * The exchange rate for each account is based on the last encountered transaction rate that is not equal
3685            to 1.0 or the previous rate for that account.
3686        * Those rates will be merged into the exchange rates main data, and later it will be used for all subsequent
3687            transactions of the same account within the whole imported and existing dataset when doing `transfer`, `check` and
3688            `zakat` operations.
3689
3690        Example:
3691            The CSV file should have the following format, rate and reference are optionals per transaction:
3692            account, desc, value, date, rate, reference
3693            For example:
3694            safe-45, 'Some text', 34872, 1988-06-30 00:00:00.000000, 1, 6554
3695        """
3696        if debug:
3697            print('import_csv', f'debug={debug}')
3698        cache: list[int] = []
3699        try:
3700            if not self.memory_mode():
3701                with open(self.import_csv_cache_path(), 'r', encoding='utf-8') as stream:
3702                    cache = json.load(stream)
3703        except Exception as e:
3704            if debug:
3705                print(e)
3706        date_formats = [
3707            '%Y-%m-%d %H:%M:%S.%f',
3708            '%Y-%m-%dT%H:%M:%S.%f',
3709            '%Y-%m-%dT%H%M%S.%f',
3710            '%Y-%m-%d',
3711        ]
3712        statistics = ImportStatistics(0, 0, 0)
3713        data: dict[int, list[CSVRecord]] = {}
3714        with open(path, newline='', encoding='utf-8') as f:
3715            i = 0
3716            for row in csv.reader(f, delimiter=','):
3717                if debug:
3718                    print(f"csv_row({i})", row, type(row))
3719                if row == self.get_transaction_csv_headers():
3720                    continue
3721                i += 1
3722                hashed = hash(tuple(row))
3723                if hashed in cache:
3724                    statistics.found += 1
3725                    continue
3726                account = row[0]
3727                desc = row[1]
3728                value = float(row[2])
3729                rate = 1.0
3730                reference = ''
3731                if row[4:5]: # Empty list if index is out of range
3732                    rate = float(row[4])
3733                if row[5:6]:
3734                    reference = row[5]
3735                date: int = 0
3736                for time_format in date_formats:
3737                    try:
3738                        date_str = row[3]
3739                        if "." not in date_str:
3740                            date_str += ".000000"
3741                        date = Time.time(datetime.datetime.strptime(date_str, time_format))
3742                        break
3743                    except Exception as e:
3744                        if debug:
3745                            print(e)
3746                record = CSVRecord(
3747                    index=i,
3748                    account=account,
3749                    desc=desc,
3750                    value=value,
3751                    date=date,
3752                    rate=rate,
3753                    reference=reference,
3754                    hashed=hashed,
3755                    error='',
3756                )
3757                if date <= 0:
3758                    record.error = 'invalid date'
3759                    statistics.bad += 1
3760                if value == 0:
3761                    record.error = 'invalid value'
3762                    statistics.bad += 1
3763                    continue
3764                if date not in data:
3765                    data[date] = []
3766                data[date].append(record)
3767
3768        if debug:
3769            print('import_csv', len(data))
3770
3771        if statistics.bad > 0:
3772            return ImportReport(
3773                statistics=statistics,
3774                bad=[
3775                    item
3776                    for sublist in data.values()
3777                    for item in sublist
3778                    if item.error
3779                ],
3780            )
3781
3782        no_lock = self.nolock()
3783        lock = self.__lock()
3784        names = self.names()
3785
3786        # sync accounts
3787        if debug:
3788            print('before-names', names, len(names))
3789        for date, rows in sorted(data.items()):
3790            new_rows: list[CSVRecord] = []
3791            for row in rows:
3792                if row.account not in names:
3793                    account_id = self.create_account(row.account)
3794                    names[row.account] = account_id
3795                account_id = names[row.account]
3796                assert account_id
3797                row.account = account_id
3798                new_rows.append(row)
3799            assert new_rows
3800            assert date in data
3801            data[date] = new_rows
3802        if debug:
3803            print('after-names', names, len(names))
3804            assert names == self.names()
3805
3806        # do ops
3807        for date, rows in sorted(data.items()):
3808            try:
3809                def process(x: CSVRecord):
3810                    x.value = self.unscale(
3811                        x.value,
3812                        decimal_places=scale_decimal_places,
3813                    ) if scale_decimal_places > 0 else x.value
3814                    if x.rate > 0:
3815                        self.exchange(account=x.account, created_time_ns=x.date, rate=x.rate)
3816                    if x.value > 0:
3817                        self.track(unscaled_value=x.value, desc=x.desc, account=x.account, created_time_ns=x.date)
3818                    elif x.value < 0:
3819                        self.subtract(unscaled_value=-x.value, desc=x.desc, account=x.account, created_time_ns=x.date)
3820                    return x.hashed
3821                len_rows = len(rows)
3822                # If records are found at the same time with different accounts in the same amount
3823                # (one positive and the other negative), this indicates it is a transfer.
3824                if len_rows > 2 or len_rows == 1:
3825                    for row in rows:
3826                        hashed = process(row)
3827                        assert hashed not in cache
3828                        cache.append(hashed)
3829                        statistics.created += 1
3830                    continue
3831                x1 = rows[0]
3832                x2 = rows[1]
3833                if x1.account == x2.account:
3834                    continue
3835                    # raise Exception(f'invalid transfer')
3836                # not transfer - same time - normal ops
3837                if abs(x1.value) != abs(x2.value) and x1.date == x2.date:
3838                    rows[1].date += 1
3839                    for row in rows:
3840                        hashed = process(row)
3841                        assert hashed not in cache
3842                        cache.append(hashed)
3843                        statistics.created += 1
3844                    continue
3845                if x1.rate > 0:
3846                    self.exchange(x1.account, created_time_ns=x1.date, rate=x1.rate)
3847                if x2.rate > 0:
3848                    self.exchange(x2.account, created_time_ns=x2.date, rate=x2.rate)
3849                x1.value = self.unscale(
3850                    x1.value,
3851                    decimal_places=scale_decimal_places,
3852                ) if scale_decimal_places > 0 else x1.value
3853                x2.value = self.unscale(
3854                    x2.value,
3855                    decimal_places=scale_decimal_places,
3856                ) if scale_decimal_places > 0 else x2.value
3857                # just transfer
3858                values = {
3859                    x1.value: x1.account,
3860                    x2.value: x2.account,
3861                }
3862                if debug:
3863                    print('values', values)
3864                if len(values) <= 1:
3865                    continue
3866                self.transfer(
3867                    unscaled_amount=abs(x1.value),
3868                    from_account=values[min(values.keys())],
3869                    to_account=values[max(values.keys())],
3870                    desc=x1.desc,
3871                    created_time_ns=x1.date,
3872                )
3873            except Exception as e:
3874                for row in rows:
3875                    _tuple = tuple()
3876                    for field in row:
3877                        _tuple += (field,)
3878                    _tuple += (e,)
3879                    bad[i] = _tuple
3880                break
3881        if not self.memory_mode():
3882            with open(self.import_csv_cache_path(), 'w', encoding='utf-8') as stream:
3883                stream.write(json.dumps(cache))
3884        if no_lock:
3885            assert lock is not None
3886            self.free(lock)
3887        report = ImportReport(
3888            statistics=statistics,
3889            bad=[
3890                item
3891                for sublist in data.values()
3892                for item in sublist
3893                if item.error
3894            ],
3895        )
3896        if debug:
3897            debug_path = f'{self.import_csv_cache_path()}.debug.json'
3898            with open(debug_path, 'w', encoding='utf-8') as file:
3899                json.dump(report, file, indent=4, cls=JSONEncoder)
3900                print(f'generated debug report @ `{debug_path}`...')
3901        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:
3907    @staticmethod
3908    def human_readable_size(size: float, decimal_places: int = 2) -> str:
3909        """
3910        Converts a size in bytes to a human-readable format (e.g., KB, MB, GB).
3911
3912        This function iterates through progressively larger units of information
3913        (B, KB, MB, GB, etc.) and divides the input size until it fits within a
3914        range that can be expressed with a reasonable number before the unit.
3915
3916        Parameters:
3917        - size (float): The size in bytes to convert.
3918        - decimal_places (int, optional): The number of decimal places to display
3919            in the result. Defaults to 2.
3920
3921        Returns:
3922        - str: A string representation of the size in a human-readable format,
3923            rounded to the specified number of decimal places. For example:
3924                - '1.50 KB' (1536 bytes)
3925                - '23.00 MB' (24117248 bytes)
3926                - '1.23 GB' (1325899906 bytes)
3927        """
3928        if type(size) not in (float, int):
3929            raise TypeError('size must be a float or integer')
3930        if type(decimal_places) != int:
3931            raise TypeError('decimal_places must be an integer')
3932        for unit in ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB']:
3933            if size < 1024.0:
3934                break
3935            size /= 1024.0
3936        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:
3938    @staticmethod
3939    def get_dict_size(obj: dict, seen: Optional[set] = None) -> float:
3940        """
3941        Recursively calculates the approximate memory size of a dictionary and its contents in bytes.
3942
3943        This function traverses the dictionary structure, accounting for the size of keys, values,
3944        and any nested objects. It handles various data types commonly found in dictionaries
3945        (e.g., lists, tuples, sets, numbers, strings) and prevents infinite recursion in case
3946        of circular references.
3947
3948        Parameters:
3949        - obj (dict): The dictionary whose size is to be calculated.
3950        - seen (set, optional): A set used internally to track visited objects
3951                             and avoid circular references. Defaults to None.
3952
3953        Returns:
3954         - float: An approximate size of the dictionary and its contents in bytes.
3955
3956        Notes:
3957        - This function is a method of the `ZakatTracker` class and is likely used to
3958          estimate the memory footprint of data structures relevant to Zakat calculations.
3959        - The size calculation is approximate as it relies on `sys.getsizeof()`, which might
3960          not account for all memory overhead depending on the Python implementation.
3961        - Circular references are handled to prevent infinite recursion.
3962        - Basic numeric types (int, float, complex) are assumed to have fixed sizes.
3963        - String sizes are estimated based on character length and encoding.
3964        """
3965        size = 0
3966        if seen is None:
3967            seen = set()
3968
3969        obj_id = id(obj)
3970        if obj_id in seen:
3971            return 0
3972
3973        seen.add(obj_id)
3974        size += sys.getsizeof(obj)
3975
3976        if isinstance(obj, dict):
3977            for k, v in obj.items():
3978                size += ZakatTracker.get_dict_size(k, seen)
3979                size += ZakatTracker.get_dict_size(v, seen)
3980        elif isinstance(obj, (list, tuple, set, frozenset)):
3981            for item in obj:
3982                size += ZakatTracker.get_dict_size(item, seen)
3983        elif isinstance(obj, (int, float, complex)):  # Handle numbers
3984            pass  # Basic numbers have a fixed size, so nothing to add here
3985        elif isinstance(obj, str):  # Handle strings
3986            size += len(obj) * sys.getsizeof(str().encode())  # Size per character in bytes
3987        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:
3989    @staticmethod
3990    def day_to_time(day: int, month: int = 6, year: int = 2024) -> Timestamp:  # افتراض أن الشهر هو يونيو والسنة 2024
3991        """
3992        Convert a specific day, month, and year into a timestamp.
3993
3994        Parameters:
3995        - day (int): The day of the month.
3996        - month (int, optional): The month of the year. Default is 6 (June).
3997        - year (int, optional): The year. Default is 2024.
3998
3999        Returns:
4000        - Timestamp: The timestamp representing the given day, month, and year.
4001
4002        Note:
4003        - This method assumes the default month and year if not provided.
4004        """
4005        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:
4007    @staticmethod
4008    def generate_random_date(start_date: datetime.datetime, end_date: datetime.datetime) -> datetime.datetime:
4009        """
4010        Generate a random date between two given dates.
4011
4012        Parameters:
4013        - start_date (datetime.datetime): The start date from which to generate a random date.
4014        - end_date (datetime.datetime): The end date until which to generate a random date.
4015
4016        Returns:
4017        - datetime.datetime: A random date between the start_date and end_date.
4018        """
4019        time_between_dates = end_date - start_date
4020        days_between_dates = time_between_dates.days
4021        random_number_of_days = random.randrange(days_between_dates)
4022        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:
4024    @staticmethod
4025    def generate_random_csv_file(path: str = 'data.csv', count: int = 1_000, with_rate: bool = False,
4026                                 debug: bool = False) -> int:
4027        """
4028        Generate a random CSV file with specified parameters.
4029        The function generates a CSV file at the specified path with the given count of rows.
4030        Each row contains a randomly generated account, description, value, and date.
4031        The value is randomly generated between 1000 and 100000,
4032        and the date is randomly generated between 1950-01-01 and 2023-12-31.
4033        If the row number is not divisible by 13, the value is multiplied by -1.
4034
4035        Parameters:
4036        - path (str, optional): The path where the CSV file will be saved. Default is 'data.csv'.
4037        - count (int, optional): The number of rows to generate in the CSV file. Default is 1000.
4038        - with_rate (bool, optional): If True, a random rate between 1.2% and 12% is added. Default is False.
4039        - debug (bool, optional): A flag indicating whether to print debug information.
4040
4041        Returns:
4042        - int: number of generated records.
4043        """
4044        if debug:
4045            print('generate_random_csv_file', f'debug={debug}')
4046        i = 0
4047        with open(path, 'w', newline='', encoding='utf-8') as csvfile:
4048            writer = csv.writer(csvfile)
4049            writer.writerow(ZakatTracker.get_transaction_csv_headers())
4050            for i in range(count):
4051                account = f'acc-{random.randint(1, count)}'
4052                desc = f'Some text {random.randint(1, count)}'
4053                value = random.randint(1000, 100000)
4054                date = ZakatTracker.generate_random_date(
4055                    datetime.datetime(1000, 1, 1),
4056                    datetime.datetime(2023, 12, 31),
4057                ).strftime('%Y-%m-%d %H:%M:%S.%f' if i % 2 == 0 else '%Y-%m-%d %H:%M:%S')
4058                if not i % 13 == 0:
4059                    value *= -1
4060                row = [account, desc, value, date]
4061                if with_rate:
4062                    rate = random.randint(1, 100) * 0.12
4063                    if debug:
4064                        print('before-append', row)
4065                    row.append(rate)
4066                    if debug:
4067                        print('after-append', row)
4068                if i % 2 == 1:
4069                    row += (Time.time(),)
4070                writer.writerow(row)
4071                i = i + 1
4072        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):
4074    @staticmethod
4075    def create_random_list(max_sum: int, min_value: int = 0, max_value: int = 10):
4076        """
4077        Creates a list of random integers whose sum does not exceed the specified maximum.
4078
4079        Parameters:
4080        - max_sum (int): The maximum allowed sum of the list elements.
4081        - min_value (int, optional): The minimum possible value for an element (inclusive).
4082        - max_value (int, optional): The maximum possible value for an element (inclusive).
4083
4084        Returns:
4085        - A list of random integers.
4086        """
4087        result = []
4088        current_sum = 0
4089
4090        while current_sum < max_sum:
4091            # Calculate the remaining space for the next element
4092            remaining_sum = max_sum - current_sum
4093            # Determine the maximum possible value for the next element
4094            next_max_value = min(remaining_sum, max_value)
4095            # Generate a random element within the allowed range
4096            next_element = random.randint(min_value, next_max_value)
4097            result.append(next_element)
4098            current_sum += next_element
4099
4100        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:
4484    def test(self, debug: bool = False) -> bool:
4485        if debug:
4486            print('test', f'debug={debug}')
4487        try:
4488
4489            self._test_core(True, debug)
4490            self._test_core(False, debug)
4491
4492            # test_names
4493            self.reset()
4494            x = "test_names"
4495            failed = False
4496            try:
4497                assert self.name(x) == ''
4498            except:
4499                failed = True
4500            assert failed
4501            assert self.names() == {}
4502            failed = False
4503            try:
4504                assert self.name(x, 'qwe') == ''
4505            except:
4506                failed = True
4507            assert failed
4508            account_id0 = self.create_account(x)
4509            assert isinstance(account_id0, AccountID)
4510            assert int(account_id0) > 0
4511            assert self.name(account_id0) == x
4512            assert self.name(account_id0, 'qwe') == 'qwe'
4513            if debug:
4514                print(self.names(keyword='qwe'))
4515            assert self.names(keyword='asd') == {}
4516            assert self.names(keyword='qwe') == {'qwe': account_id0}
4517
4518            # test_create_account
4519            account_name = "test_account"
4520            assert self.names(keyword=account_name) == {}
4521            account_id = self.create_account(account_name)
4522            assert isinstance(account_id, AccountID)
4523            assert int(account_id) > 0
4524            assert account_id in self.__vault.account
4525            assert self.name(account_id) == account_name
4526            assert self.names(keyword=account_name) == {account_name: account_id}
4527
4528            failed = False
4529            try:
4530                self.create_account(account_name)
4531            except:
4532                failed = True
4533            assert failed
4534
4535            # bad are names is forbidden
4536
4537            for bad_name in [
4538                None,
4539                '',
4540                Time.time(),
4541                -Time.time(),
4542                f'{Time.time()}',
4543                f'{-Time.time()}',
4544                0.0,
4545                '0.0',
4546                ' ',
4547            ]:
4548                failed = False
4549                try:
4550                    self.create_account(bad_name)
4551                except:
4552                    failed = True
4553                assert failed
4554
4555            # rename account
4556            assert self.name(account_id) == account_name
4557            assert self.name(account_id, 'asd') == 'asd'
4558            assert self.name(account_id) == 'asd'
4559            # use old and not used name
4560            account_id2 = self.create_account(account_name)
4561            assert int(account_id2) > 0
4562            assert account_id != account_id2
4563            assert self.name(account_id2) == account_name
4564            assert self.names(keyword=account_name) == {account_name: account_id2}
4565
4566            assert self.__history()
4567            count = len(self.__vault.history)
4568            if debug:
4569                print('history-count', count)
4570            assert count == 8
4571
4572            assert self.recall(dry=False, debug=debug)
4573            assert self.name(account_id2) == ''
4574            assert self.account_exists(account_id2)
4575            assert self.recall(dry=False, debug=debug)
4576            assert not self.account_exists(account_id2)
4577            assert self.recall(dry=False, debug=debug)
4578            assert self.name(account_id) == account_name
4579            assert self.recall(dry=False, debug=debug)
4580            assert self.account_exists(account_id)
4581            assert self.recall(dry=False, debug=debug)
4582            assert not self.account_exists(account_id)
4583            assert self.names(keyword='qwe') == {'qwe': account_id0}
4584            assert self.recall(dry=False, debug=debug)
4585            assert self.names(keyword='qwe') == {}
4586            assert self.name(account_id0) == x
4587            assert self.recall(dry=False, debug=debug)
4588            assert self.name(account_id0) == ''
4589            assert self.account_exists(account_id0)
4590            assert self.recall(dry=False, debug=debug)
4591            assert not self.account_exists(account_id0)
4592            assert not self.recall(dry=False, debug=debug)
4593
4594            # Not allowed for duplicate transactions in the same account and time
4595
4596            created = Time.time()
4597            same_account_id = self.create_account('same')
4598            self.track(100, 'test-1', same_account_id, True, created)
4599            failed = False
4600            try:
4601                self.track(50, 'test-1', same_account_id, True, created)
4602            except:
4603                failed = True
4604            assert failed is True
4605
4606            self.reset()
4607
4608            # Same account transfer
4609            for x in [1, 'a', True, 1.8, None]:
4610                failed = False
4611                try:
4612                    self.transfer(1, x, x, 'same-account', debug=debug)
4613                except:
4614                    failed = True
4615                assert failed is True
4616
4617            # Always preserve box age during transfer
4618
4619            series: list[tuple[int, int]] = [
4620                (30, 4),
4621                (60, 3),
4622                (90, 2),
4623            ]
4624            case = {
4625                3000: {
4626                    'series': series,
4627                    'rest': 15000,
4628                },
4629                6000: {
4630                    'series': series,
4631                    'rest': 12000,
4632                },
4633                9000: {
4634                    'series': series,
4635                    'rest': 9000,
4636                },
4637                18000: {
4638                    'series': series,
4639                    'rest': 0,
4640                },
4641                27000: {
4642                    'series': series,
4643                    'rest': -9000,
4644                },
4645                36000: {
4646                    'series': series,
4647                    'rest': -18000,
4648                },
4649            }
4650
4651            selected_time = Time.time() - ZakatTracker.TimeCycle()
4652            ages_account_id = self.create_account('ages')
4653            future_account_id = self.create_account('future')
4654
4655            for total in case:
4656                if debug:
4657                    print('--------------------------------------------------------')
4658                    print(f'case[{total}]', case[total])
4659                for x in case[total]['series']:
4660                    self.track(
4661                        unscaled_value=x[0],
4662                        desc=f'test-{x} ages',
4663                        account=ages_account_id,
4664                        created_time_ns=selected_time * x[1],
4665                    )
4666
4667                unscaled_total = self.unscale(total)
4668                if debug:
4669                    print('unscaled_total', unscaled_total)
4670                refs = self.transfer(
4671                    unscaled_amount=unscaled_total,
4672                    from_account=ages_account_id,
4673                    to_account=future_account_id,
4674                    desc='Zakat Movement',
4675                    debug=debug,
4676                )
4677
4678                if debug:
4679                    print('refs', refs)
4680
4681                ages_cache_balance = self.balance(ages_account_id)
4682                ages_fresh_balance = self.balance(ages_account_id, False)
4683                rest = case[total]['rest']
4684                if debug:
4685                    print('source', ages_cache_balance, ages_fresh_balance, rest)
4686                assert ages_cache_balance == rest
4687                assert ages_fresh_balance == rest
4688
4689                future_cache_balance = self.balance(future_account_id)
4690                future_fresh_balance = self.balance(future_account_id, False)
4691                if debug:
4692                    print('target', future_cache_balance, future_fresh_balance, total)
4693                    print('refs', refs)
4694                assert future_cache_balance == total
4695                assert future_fresh_balance == total
4696
4697                # TODO: check boxes times for `ages` should equal box times in `future`
4698                for ref in self.__vault.account[ages_account_id].box:
4699                    ages_capital = self.__vault.account[ages_account_id].box[ref].capital
4700                    ages_rest = self.__vault.account[ages_account_id].box[ref].rest
4701                    future_capital = 0
4702                    if ref in self.__vault.account[future_account_id].box:
4703                        future_capital = self.__vault.account[future_account_id].box[ref].capital
4704                    future_rest = 0
4705                    if ref in self.__vault.account[future_account_id].box:
4706                        future_rest = self.__vault.account[future_account_id].box[ref].rest
4707                    if ages_capital != 0 and future_capital != 0 and future_rest != 0:
4708                        if debug:
4709                            print('================================================================')
4710                            print('ages', ages_capital, ages_rest)
4711                            print('future', future_capital, future_rest)
4712                        if ages_rest == 0:
4713                            assert ages_capital == future_capital
4714                        elif ages_rest < 0:
4715                            assert -ages_capital == future_capital
4716                        elif ages_rest > 0:
4717                            assert ages_capital == ages_rest + future_capital
4718                self.reset()
4719                assert len(self.__vault.history) == 0
4720
4721            assert self.__history()
4722            assert self.__history(False) is False
4723            assert self.__history() is False
4724            assert self.__history(True)
4725            assert self.__history()
4726            if debug:
4727                print('####################################################################')
4728
4729            wallet_account_id = self.create_account('wallet')
4730            safe_account_id = self.create_account('safe')
4731            bank_account_id = self.create_account('bank')
4732            transaction = [
4733                (
4734                    20, wallet_account_id, AccountID(1), -2000, -2000, -2000, 1, 1,
4735                    2000, 2000, 2000, 1, 1,
4736                ),
4737                (
4738                    750, wallet_account_id, safe_account_id, -77000, -77000, -77000, 2, 2,
4739                    75000, 75000, 75000, 1, 1,
4740                ),
4741                (
4742                    600, safe_account_id, bank_account_id, 15000, 15000, 15000, 1, 2,
4743                    60000, 60000, 60000, 1, 1,
4744                ),
4745            ]
4746            for z in transaction:
4747                lock = self.lock()
4748                x = z[1]
4749                y = z[2]
4750                self.transfer(
4751                    unscaled_amount=z[0],
4752                    from_account=x,
4753                    to_account=y,
4754                    desc='test-transfer',
4755                    debug=debug,
4756                )
4757                zz = self.balance(x)
4758                if debug:
4759                    print(zz, z)
4760                assert zz == z[3]
4761                xx = self.accounts()[x]
4762                assert xx.balance == z[3]
4763                assert self.balance(x, False) == z[4]
4764                assert xx.balance == z[4]
4765
4766                s = 0
4767                log = self.__vault.account[x].log
4768                for i in log:
4769                    s += log[i].value
4770                if debug:
4771                    print('s', s, 'z[5]', z[5])
4772                assert s == z[5]
4773
4774                assert self.box_size(x) == z[6]
4775                assert self.log_size(x) == z[7]
4776
4777                yy = self.accounts()[y]
4778                assert self.balance(y) == z[8]
4779                assert yy.balance == z[8]
4780                assert self.balance(y, False) == z[9]
4781                assert yy.balance == z[9]
4782
4783                s = 0
4784                log = self.__vault.account[y].log
4785                for i in log:
4786                    s += log[i].value
4787                assert s == z[10]
4788
4789                assert self.box_size(y) == z[11]
4790                assert self.log_size(y) == z[12]
4791                assert lock is not None
4792                assert self.free(lock)
4793
4794            if debug:
4795                pp().pprint(self.check(2.17))
4796
4797            assert self.nolock()
4798            history_count = len(self.__vault.history)
4799            transaction_count = len(transaction)
4800            if debug:
4801                print('history-count', history_count, transaction_count)
4802            assert history_count == transaction_count * 3
4803            assert not self.free(Time.time())
4804            assert self.free(self.lock())
4805            assert self.nolock()
4806            assert len(self.__vault.history) == transaction_count * 3
4807
4808            # recall
4809
4810            assert self.nolock()
4811            for i in range(transaction_count * 3, 0, -1):
4812                assert len(self.__vault.history) == i
4813                assert self.recall(dry=False, debug=debug) is True
4814            assert len(self.__vault.history) == 0
4815            assert self.recall(dry=False, debug=debug) is False
4816            assert len(self.__vault.history) == 0
4817
4818            # exchange
4819
4820            cash_account_id = self.create_account('cash')
4821            self.exchange(cash_account_id, 25, 3.75, '2024-06-25')
4822            self.exchange(cash_account_id, 22, 3.73, '2024-06-22')
4823            self.exchange(cash_account_id, 15, 3.69, '2024-06-15')
4824            self.exchange(cash_account_id, 10, 3.66)
4825
4826            assert self.nolock()
4827
4828            bank_account_id = self.create_account('bank')
4829            for i in range(1, 30):
4830                exchange = self.exchange(cash_account_id, i)
4831                rate, description, created = exchange.rate, exchange.description, exchange.time
4832                if debug:
4833                    print(i, rate, description, created)
4834                assert created
4835                if i < 10:
4836                    assert rate == 1
4837                    assert description is None
4838                elif i == 10:
4839                    assert rate == 3.66
4840                    assert description is None
4841                elif i < 15:
4842                    assert rate == 3.66
4843                    assert description is None
4844                elif i == 15:
4845                    assert rate == 3.69
4846                    assert description is not None
4847                elif i < 22:
4848                    assert rate == 3.69
4849                    assert description is not None
4850                elif i == 22:
4851                    assert rate == 3.73
4852                    assert description is not None
4853                elif i >= 25:
4854                    assert rate == 3.75
4855                    assert description is not None
4856                exchange = self.exchange(bank_account_id, i)
4857                rate, description, created = exchange.rate, exchange.description, exchange.time
4858                if debug:
4859                    print(i, rate, description, created)
4860                assert created
4861                assert rate == 1
4862                assert description is None
4863
4864            assert len(self.__vault.exchange) == 1
4865            assert len(self.exchanges()) == 1
4866            self.__vault.exchange.clear()
4867            assert len(self.__vault.exchange) == 0
4868            assert len(self.exchanges()) == 0
4869            self.reset()
4870
4871            # حفظ أسعار الصرف باستخدام التواريخ بالنانو ثانية
4872            cash_account_id = self.create_account('cash')
4873            self.exchange(cash_account_id, ZakatTracker.day_to_time(25), 3.75, '2024-06-25')
4874            self.exchange(cash_account_id, ZakatTracker.day_to_time(22), 3.73, '2024-06-22')
4875            self.exchange(cash_account_id, ZakatTracker.day_to_time(15), 3.69, '2024-06-15')
4876            self.exchange(cash_account_id, ZakatTracker.day_to_time(10), 3.66)
4877
4878            assert self.nolock()
4879
4880            test_account_id = self.create_account('test')
4881            for i in [x * 0.12 for x in range(-15, 21)]:
4882                if i <= 0:
4883                    assert self.exchange(test_account_id, Time.time(), i, f'range({i})') == Exchange()
4884                else:
4885                    assert self.exchange(test_account_id, Time.time(), i, f'range({i})') is not Exchange()
4886
4887            assert self.nolock()
4888
4889           # اختبار النتائج باستخدام التواريخ بالنانو ثانية
4890            bank_account_id = self.create_account('bank')
4891            for i in range(1, 31):
4892                timestamp_ns = ZakatTracker.day_to_time(i)
4893                exchange = self.exchange(cash_account_id, timestamp_ns)
4894                rate, description, created = exchange.rate, exchange.description, exchange.time
4895                if debug:
4896                    print(i, rate, description, created)
4897                assert created
4898                if i < 10:
4899                    assert rate == 1
4900                    assert description is None
4901                elif i == 10:
4902                    assert rate == 3.66
4903                    assert description is None
4904                elif i < 15:
4905                    assert rate == 3.66
4906                    assert description is None
4907                elif i == 15:
4908                    assert rate == 3.69
4909                    assert description is not None
4910                elif i < 22:
4911                    assert rate == 3.69
4912                    assert description is not None
4913                elif i == 22:
4914                    assert rate == 3.73
4915                    assert description is not None
4916                elif i >= 25:
4917                    assert rate == 3.75
4918                    assert description is not None
4919                exchange = self.exchange(bank_account_id, i)
4920                rate, description, created = exchange.rate, exchange.description, exchange.time
4921                if debug:
4922                    print(i, rate, description, created)
4923                assert created
4924                assert rate == 1
4925                assert description is None
4926
4927            assert self.nolock()
4928            if debug:
4929                print(self.__vault.history, len(self.__vault.history))
4930            for _ in range(len(self.__vault.history)):
4931                assert self.recall(dry=False, debug=debug)
4932            assert not self.recall(dry=False, debug=debug)
4933
4934            self.reset()
4935
4936            # test transfer between accounts with different exchange rate
4937
4938            a_SAR = self.create_account('Bank (SAR)')
4939            b_USD = self.create_account('Bank (USD)')
4940            c_SAR = self.create_account('Safe (SAR)')
4941            # 0: track, 1: check-exchange, 2: do-exchange, 3: transfer
4942            for case in [
4943                (0, a_SAR, 'SAR Gift', 1000, 100000),
4944                (1, a_SAR, 1),
4945                (0, b_USD, 'USD Gift', 500, 50000),
4946                (1, b_USD, 1),
4947                (2, b_USD, 3.75),
4948                (1, b_USD, 3.75),
4949                (3, 100, b_USD, a_SAR, '100 USD -> SAR', 40000, 137500),
4950                (0, c_SAR, 'Salary', 750, 75000),
4951                (3, 375, c_SAR, b_USD, '375 SAR -> USD', 37500, 50000),
4952                (3, 3.75, a_SAR, b_USD, '3.75 SAR -> USD', 137125, 50100),
4953            ]:
4954                if debug:
4955                    print('case', case)
4956                match (case[0]):
4957                    case 0:  # track
4958                        _, account, desc, x, balance = case
4959                        self.track(unscaled_value=x, desc=desc, account=account, debug=debug)
4960
4961                        cached_value = self.balance(account, cached=True)
4962                        fresh_value = self.balance(account, cached=False)
4963                        if debug:
4964                            print('account', account, 'cached_value', cached_value, 'fresh_value', fresh_value)
4965                        assert cached_value == balance
4966                        assert fresh_value == balance
4967                    case 1:  # check-exchange
4968                        _, account, expected_rate = case
4969                        t_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug)
4970                        if debug:
4971                            print('t-exchange', t_exchange)
4972                        assert t_exchange.rate == expected_rate
4973                    case 2:  # do-exchange
4974                        _, account, rate = case
4975                        self.exchange(account, rate=rate, debug=debug)
4976                        b_exchange = self.exchange(account, created_time_ns=Time.time(), debug=debug)
4977                        if debug:
4978                            print('b-exchange', b_exchange)
4979                        assert b_exchange.rate == rate
4980                    case 3:  # transfer
4981                        _, x, a, b, desc, a_balance, b_balance = case
4982                        self.transfer(x, a, b, desc, debug=debug)
4983
4984                        cached_value = self.balance(a, cached=True)
4985                        fresh_value = self.balance(a, cached=False)
4986                        if debug:
4987                            print(
4988                                'account', a,
4989                                'cached_value', cached_value,
4990                                'fresh_value', fresh_value,
4991                                'a_balance', a_balance,
4992                            )
4993                        assert cached_value == a_balance
4994                        assert fresh_value == a_balance
4995
4996                        cached_value = self.balance(b, cached=True)
4997                        fresh_value = self.balance(b, cached=False)
4998                        if debug:
4999                            print('account', b, 'cached_value', cached_value, 'fresh_value', fresh_value)
5000                        assert cached_value == b_balance
5001                        assert fresh_value == b_balance
5002
5003            # Transfer all in many chunks randomly from B to A
5004            a_SAR_balance = 137125
5005            b_USD_balance = 50100
5006            b_USD_exchange = self.exchange(b_USD)
5007            amounts = ZakatTracker.create_random_list(b_USD_balance, max_value=1000)
5008            if debug:
5009                print('amounts', amounts)
5010            i = 0
5011            for x in amounts:
5012                if debug:
5013                    print(f'{i} - transfer-with-exchange({x})')
5014                self.transfer(
5015                    unscaled_amount=self.unscale(x),
5016                    from_account=b_USD,
5017                    to_account=a_SAR,
5018                    desc=f'{x} USD -> SAR',
5019                    debug=debug,
5020                )
5021
5022                b_USD_balance -= x
5023                cached_value = self.balance(b_USD, cached=True)
5024                fresh_value = self.balance(b_USD, cached=False)
5025                if debug:
5026                    print('account', b_USD, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
5027                          b_USD_balance)
5028                assert cached_value == b_USD_balance
5029                assert fresh_value == b_USD_balance
5030
5031                a_SAR_balance += int(x * b_USD_exchange.rate)
5032                cached_value = self.balance(a_SAR, cached=True)
5033                fresh_value = self.balance(a_SAR, cached=False)
5034                if debug:
5035                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
5036                          a_SAR_balance, 'rate', b_USD_exchange.rate)
5037                assert cached_value == a_SAR_balance
5038                assert fresh_value == a_SAR_balance
5039                i += 1
5040
5041            # Transfer all in many chunks randomly from C to A
5042            c_SAR_balance = 37500
5043            amounts = ZakatTracker.create_random_list(c_SAR_balance, max_value=1000)
5044            if debug:
5045                print('amounts', amounts)
5046            i = 0
5047            for x in amounts:
5048                if debug:
5049                    print(f'{i} - transfer-with-exchange({x})')
5050                self.transfer(
5051                    unscaled_amount=self.unscale(x),
5052                    from_account=c_SAR,
5053                    to_account=a_SAR,
5054                    desc=f'{x} SAR -> a_SAR',
5055                    debug=debug,
5056                )
5057
5058                c_SAR_balance -= x
5059                cached_value = self.balance(c_SAR, cached=True)
5060                fresh_value = self.balance(c_SAR, cached=False)
5061                if debug:
5062                    print('account', c_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'excepted',
5063                          c_SAR_balance)
5064                assert cached_value == c_SAR_balance
5065                assert fresh_value == c_SAR_balance
5066
5067                a_SAR_balance += x
5068                cached_value = self.balance(a_SAR, cached=True)
5069                fresh_value = self.balance(a_SAR, cached=False)
5070                if debug:
5071                    print('account', a_SAR, 'cached_value', cached_value, 'fresh_value', fresh_value, 'expected',
5072                          a_SAR_balance)
5073                assert cached_value == a_SAR_balance
5074                assert fresh_value == a_SAR_balance
5075                i += 1
5076
5077            assert self.save(f'accounts-transfer-with-exchange-rates.{self.ext()}')
5078
5079            # check & zakat with exchange rates for many cycles
5080
5081            lock = None
5082            safe_account_id = self.create_account('safe')
5083            cave_account_id = self.create_account('cave')
5084            for rate, values in {
5085                1: {
5086                    'in': [1000, 2000, 10000],
5087                    'exchanged': [100000, 200000, 1000000],
5088                    'out': [2500, 5000, 73140],
5089                },
5090                3.75: {
5091                    'in': [200, 1000, 5000],
5092                    'exchanged': [75000, 375000, 1875000],
5093                    'out': [1875, 9375, 137138],
5094                },
5095            }.items():
5096                a, b, c = values['in']
5097                m, n, o = values['exchanged']
5098                x, y, z = values['out']
5099                if debug:
5100                    print('rate', rate, 'values', values)
5101                for case in [
5102                    (a, safe_account_id, Time.time() - ZakatTracker.TimeCycle(), [
5103                        {safe_account_id: {0: {'below_nisab': x}}},
5104                    ], False, m),
5105                    (b, safe_account_id, Time.time() - ZakatTracker.TimeCycle(), [
5106                        {safe_account_id: {0: {'count': 1, 'total': y}}},
5107                    ], True, n),
5108                    (c, cave_account_id, Time.time() - (ZakatTracker.TimeCycle() * 3), [
5109                        {cave_account_id: {0: {'count': 3, 'total': z}}},
5110                    ], True, o),
5111                ]:
5112                    if debug:
5113                        print(f'############# check(rate: {rate}) #############')
5114                        print('case', case)
5115                    self.reset()
5116                    self.exchange(account=case[1], created_time_ns=case[2], rate=rate)
5117                    self.track(
5118                        unscaled_value=case[0],
5119                        desc='test-check',
5120                        account=case[1],
5121                        created_time_ns=case[2],
5122                    )
5123                    assert self.snapshot()
5124
5125                    # assert self.nolock()
5126                    # history_size = len(self.__vault.history)
5127                    # print('history_size', history_size)
5128                    # assert history_size == 2
5129                    lock = self.lock()
5130                    assert lock
5131                    assert not self.nolock()
5132                    report = self.check(2.17, None, debug)
5133                    if debug:
5134                        print('[report]', report)
5135                    assert case[4] == report.valid
5136                    assert case[5] == report.summary.total_wealth
5137                    assert case[5] == report.summary.total_zakatable_amount
5138                    if report.valid:
5139                        if debug:
5140                            pp().pprint(report.plan)
5141                        assert report.plan
5142                        assert self.zakat(report, debug=debug)
5143                        if debug:
5144                            pp().pprint(self.__vault)
5145                        self._test_storage(debug=debug)
5146
5147                        for x in report.plan:
5148                            assert case[1] == x
5149                            if report.plan[x][0].below_nisab:
5150                                if debug:
5151                                    print('[assert]', report.plan[x][0].total, case[3][0][x][0]['below_nisab'])
5152                                assert report.plan[x][0].total == case[3][0][x][0]['below_nisab']
5153                            else:
5154                                if debug:
5155                                    print('[assert]', int(report.summary.total_zakat_due), case[3][0][x][0]['total'])
5156                                    print('[assert]', int(report.plan[x][0].total), case[3][0][x][0]['total'])
5157                                    print('[assert]', report.plan[x][0].count ,case[3][0][x][0]['count'])
5158                                assert int(report.summary.total_zakat_due) == case[3][0][x][0]['total']
5159                                assert int(report.plan[x][0].total) == case[3][0][x][0]['total']
5160                                assert report.plan[x][0].count == case[3][0][x][0]['count']
5161                    else:
5162                        if debug:
5163                            pp().pprint(report)
5164                        result = self.zakat(report, debug=debug)
5165                        if debug:
5166                            print('zakat-result', result, case[4])
5167                        assert result == case[4]
5168                        report = self.check(2.17, None, debug)
5169                        assert report.valid is False
5170            self._test_storage(account_id=cave_account_id, debug=debug)
5171
5172            # recall after zakat
5173
5174            history_size = len(self.__vault.history)
5175            if debug:
5176                print('history_size', history_size)
5177            assert history_size == 3
5178            assert not self.nolock()
5179            assert self.recall(dry=False, debug=debug) is False
5180            self.free(lock)
5181            assert self.nolock()
5182
5183            for i in range(3, 0, -1):
5184                history_size = len(self.__vault.history)
5185                if debug:
5186                    print('history_size', history_size)
5187                assert history_size == i
5188                assert self.recall(dry=False, debug=debug) is True
5189
5190            assert self.nolock()
5191            assert self.recall(dry=False, debug=debug) is False
5192
5193            history_size = len(self.__vault.history)
5194            if debug:
5195                print('history_size', history_size)
5196            assert history_size == 0
5197
5198            account_size = len(self.__vault.account)
5199            if debug:
5200                print('account_size', account_size)
5201            assert account_size == 0
5202
5203            report_size = len(self.__vault.report)
5204            if debug:
5205                print('report_size', report_size)
5206            assert report_size == 0
5207
5208            assert self.nolock()
5209
5210            # csv
5211
5212            csv_count = 1000
5213
5214            for with_rate, path in {
5215                False: 'test-import_csv-no-exchange',
5216                True: 'test-import_csv-with-exchange',
5217            }.items():
5218
5219                if debug:
5220                    print('test_import_csv', with_rate, path)
5221
5222                csv_path = path + '.csv'
5223                if os.path.exists(csv_path):
5224                    os.remove(csv_path)
5225                c = self.generate_random_csv_file(csv_path, csv_count, with_rate, debug)
5226                if debug:
5227                    print('generate_random_csv_file', c)
5228                assert c == csv_count
5229                assert os.path.getsize(csv_path) > 0
5230                cache_path = self.import_csv_cache_path()
5231                if os.path.exists(cache_path):
5232                    os.remove(cache_path)
5233                self.reset()
5234                lock = self.lock()
5235                import_report = self.import_csv(csv_path, debug=debug)
5236                bad_count = len(import_report.bad)
5237                if debug:
5238                    print(f'csv-imported: {import_report.statistics} = count({csv_count})')
5239                    print('bad', import_report.bad)
5240                assert import_report.statistics.created + import_report.statistics.found + bad_count == csv_count
5241                assert import_report.statistics.created == csv_count
5242                assert bad_count == 0
5243                assert bad_count == import_report.statistics.bad
5244                tmp_size = os.path.getsize(cache_path)
5245                assert tmp_size > 0
5246
5247                import_report_2 = self.import_csv(csv_path, debug=debug)
5248                bad_2_count = len(import_report_2.bad)
5249                if debug:
5250                    print(f'csv-imported: {import_report_2}')
5251                    print('bad', import_report_2.bad)
5252                assert tmp_size == os.path.getsize(cache_path)
5253                assert import_report_2.statistics.created + import_report_2.statistics.found + bad_2_count == csv_count
5254                assert import_report.statistics.created == import_report_2.statistics.found
5255                assert bad_count == bad_2_count
5256                assert import_report_2.statistics.found == csv_count
5257                assert bad_2_count == 0
5258                assert bad_2_count == import_report_2.statistics.bad
5259                assert import_report_2.statistics.created == 0
5260
5261                # payment parts
5262
5263                positive_parts = self.build_payment_parts(100, positive_only=True)
5264                assert self.check_payment_parts(positive_parts) != 0
5265                assert self.check_payment_parts(positive_parts) != 0
5266                all_parts = self.build_payment_parts(300, positive_only=False)
5267                assert self.check_payment_parts(all_parts) != 0
5268                assert self.check_payment_parts(all_parts) != 0
5269                if debug:
5270                    pp().pprint(positive_parts)
5271                    pp().pprint(all_parts)
5272                # dynamic discount
5273                suite = []
5274                count = 3
5275                for exceed in [False, True]:
5276                    case = []
5277                    for part in [positive_parts, all_parts]:
5278                        #part = parts.copy()
5279                        demand = part.demand
5280                        if debug:
5281                            print(demand, part.total)
5282                        i = 0
5283                        z = demand / count
5284                        cp = PaymentParts(
5285                            demand=demand,
5286                            exceed=exceed,
5287                            total=part.total,
5288                        )
5289                        j = ''
5290                        for x, y in part.account.items():
5291                            x_exchange = self.exchange(x)
5292                            zz = self.exchange_calc(z, 1, x_exchange.rate)
5293                            if exceed and zz <= demand:
5294                                i += 1
5295                                y.part = zz
5296                                if debug:
5297                                    print(exceed, y)
5298                                cp.account[x] = y
5299                                case.append(y)
5300                            elif not exceed and y.balance >= zz:
5301                                i += 1
5302                                y.part = zz
5303                                if debug:
5304                                    print(exceed, y)
5305                                cp.account[x] = y
5306                                case.append(y)
5307                            j = x
5308                            if i >= count:
5309                                break
5310                        if debug:
5311                            print('[debug]', j)
5312                            print('[debug]', cp.account[j])
5313                        if cp.account[j] != AccountPaymentPart(0.0, 0.0, 0.0):
5314                            suite.append(cp)
5315                if debug:
5316                    print('suite', len(suite))
5317                for case in suite:
5318                    if debug:
5319                        print('case', case)
5320                    result = self.check_payment_parts(case)
5321                    if debug:
5322                        print('check_payment_parts', result, f'exceed: {exceed}')
5323                    assert result == 0
5324
5325                    report = self.check(2.17, None, debug)
5326                    if debug:
5327                        print('valid', report.valid)
5328                    zakat_result = self.zakat(report, parts=case, debug=debug)
5329                    if debug:
5330                        print('zakat-result', zakat_result)
5331                    assert report.valid == zakat_result
5332                    # test verified zakat report is required
5333                    if zakat_result:
5334                        failed = False
5335                        try:
5336                            self.zakat(report, parts=case, debug=debug)
5337                        except:
5338                            failed = True
5339                        assert failed
5340
5341                assert self.free(lock)
5342
5343            assert self.save(path + f'.{self.ext()}')
5344
5345            assert self.save(f'1000-transactions-test.{self.ext()}')
5346            return True
5347        except Exception as e:
5348            if self.__debug_output:
5349                pp().pprint(self.__vault)
5350                print('============================================================================')
5351                pp().pprint(self.__debug_output)
5352            assert self.save(f'test-snapshot.{self.ext()}')
5353            raise e
class AccountID(builtins.str):
236class AccountID(str):
237    """
238    A class representing an Account ID, which is a string that must be a positive integer greater than zero.
239    Inherits from str, so it behaves like a string.
240    """
241
242    def __new__(cls, value):
243        """
244        Creates a new AccountID instance.
245
246        Parameters:
247        - value (str): The string value to be used as the AccountID.
248
249        Raises:
250        - ValueError: If the provided value is not a valid AccountID.
251
252        Returns:
253        - AccountID: A new AccountID instance.
254        """
255        if isinstance(value, Timestamp):
256            value = str(value) # convert timestamp to string
257        if not cls.is_valid_account_id(value):
258            raise ValueError(f"Invalid AccountID: '{value}'")
259        return super().__new__(cls, value)
260
261    @staticmethod
262    def is_valid_account_id(s: str) -> bool:
263        """
264        Checks if a string is a valid AccountID (positive integer greater than zero).
265
266        Parameters:
267        - s (str): The string to check.
268
269        Returns:
270         - bool: True if the string is a valid AccountID, False otherwise.
271        """
272        if not s:
273            return False
274
275        try:
276            if s[0] == '0':
277                return False
278            if s.startswith('-'):
279                return False
280            if not s.isdigit():
281                return False
282        except:
283            pass
284
285        try:
286            num = int(s)
287            return num > 0
288        except ValueError:
289            return False
290
291    @classmethod
292    def test(cls, debug: bool = False):
293        """
294        Runs tests for the AccountID class to ensure it behaves correctly.
295
296        This method tests various valid and invalid input strings to verify that:
297            - Valid AccountIDs are created successfully.
298            - Invalid AccountIDs raise ValueError exceptions.
299        """
300        test_data = {
301            "123": True,
302            "0": False,
303            "01": False,
304            "-1": False,
305            "abc": False,
306            "12.3": False,
307            "": False,
308            "9999999999999999999999999999999999999": True,
309            "1": True,
310            "10": True,
311            "000000000000000001": False,
312            " ": False,
313            "1 ": False,
314            " 1": False,
315            "1.0": False,
316            Timestamp(12345): True, # Test timestamp input
317        }
318
319        for input_value, expected_output in test_data.items():
320            if expected_output:
321                try:
322                    account_id = cls(input_value)
323                    if debug:
324                        print(f'"{str(account_id)}", "{input_value}"')
325                    if isinstance(input_value, Timestamp):
326                        input_value = str(input_value)
327                    assert str(account_id) == input_value, f"Test failed for valid input: '{input_value}'"
328                except ValueError as e:
329                    assert False, f"Unexpected ValueError for valid input: '{input_value}': {e}"
330            else:
331                try:
332                    cls(input_value)
333                    assert False, f"Expected ValueError for invalid input: '{input_value}'"
334                except ValueError as e:
335                    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)
242    def __new__(cls, value):
243        """
244        Creates a new AccountID instance.
245
246        Parameters:
247        - value (str): The string value to be used as the AccountID.
248
249        Raises:
250        - ValueError: If the provided value is not a valid AccountID.
251
252        Returns:
253        - AccountID: A new AccountID instance.
254        """
255        if isinstance(value, Timestamp):
256            value = str(value) # convert timestamp to string
257        if not cls.is_valid_account_id(value):
258            raise ValueError(f"Invalid AccountID: '{value}'")
259        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:
261    @staticmethod
262    def is_valid_account_id(s: str) -> bool:
263        """
264        Checks if a string is a valid AccountID (positive integer greater than zero).
265
266        Parameters:
267        - s (str): The string to check.
268
269        Returns:
270         - bool: True if the string is a valid AccountID, False otherwise.
271        """
272        if not s:
273            return False
274
275        try:
276            if s[0] == '0':
277                return False
278            if s.startswith('-'):
279                return False
280            if not s.isdigit():
281                return False
282        except:
283            pass
284
285        try:
286            num = int(s)
287            return num > 0
288        except ValueError:
289            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):
291    @classmethod
292    def test(cls, debug: bool = False):
293        """
294        Runs tests for the AccountID class to ensure it behaves correctly.
295
296        This method tests various valid and invalid input strings to verify that:
297            - Valid AccountIDs are created successfully.
298            - Invalid AccountIDs raise ValueError exceptions.
299        """
300        test_data = {
301            "123": True,
302            "0": False,
303            "01": False,
304            "-1": False,
305            "abc": False,
306            "12.3": False,
307            "": False,
308            "9999999999999999999999999999999999999": True,
309            "1": True,
310            "10": True,
311            "000000000000000001": False,
312            " ": False,
313            "1 ": False,
314            " 1": False,
315            "1.0": False,
316            Timestamp(12345): True, # Test timestamp input
317        }
318
319        for input_value, expected_output in test_data.items():
320            if expected_output:
321                try:
322                    account_id = cls(input_value)
323                    if debug:
324                        print(f'"{str(account_id)}", "{input_value}"')
325                    if isinstance(input_value, Timestamp):
326                        input_value = str(input_value)
327                    assert str(account_id) == input_value, f"Test failed for valid input: '{input_value}'"
328                except ValueError as e:
329                    assert False, f"Unexpected ValueError for valid input: '{input_value}': {e}"
330            else:
331                try:
332                    cls(input_value)
333                    assert False, f"Expected ValueError for invalid input: '{input_value}'"
334                except ValueError as e:
335                    pass  # Expected exception

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

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

@dataclasses.dataclass
class AccountDetails:
338@dataclasses.dataclass
339class AccountDetails:
340    """
341    Details of an account.
342
343    Attributes:
344    - account_id: The unique identifier (ID) of the account.
345    - account_name: Human-readable name of the account.
346    - balance: The current cached balance of the account.
347    """
348    account_id: AccountID
349    account_name: str
350    balance: int

Details of an account.

Attributes:

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

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

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

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

@dataclasses.dataclass
class Box(zakat.StrictDataclass):
446@dataclasses.dataclass
447class Box(
448        StrictDataclass,
449        # ImmutableWithSelectiveFreeze,
450    ):
451    """
452    Represents a financial box with capital, remaining value, and zakat details.
453
454    Attributes:
455    - capital (int): The initial capital value of the box.
456    - rest (int): The current remaining value within the box.
457    - zakat (BoxZakat): A `BoxZakat` object containing the accumulated zakat information for the box.
458    """
459    capital: int #= dataclasses.field(metadata={"frozen": True})
460    rest: int
461    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):
464@dataclasses.dataclass
465class Log(StrictDataclass):
466    """
467    Represents a log entry for an account.
468
469    Attributes:
470    - value: The value of the log entry.
471    - desc: A description of the log entry.
472    - ref: An optional timestamp reference.
473    - file: A dictionary mapping timestamps to file paths.
474    """
475    value: int
476    desc: str
477    ref: Optional[Timestamp]
478    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):
481@dataclasses.dataclass
482class Account(StrictDataclass):
483    """
484    Represents a financial account.
485
486    Attributes:
487    - balance: The current balance of the account.
488    - created: The timestamp when the account was created.
489    - name: The name of the account.
490    - box: A dictionary mapping timestamps to Box objects.
491    - count: A counter for logs, initialized to 0.
492    - log: A dictionary mapping timestamps to Log objects.
493    - hide: A boolean indicating whether the account is hidden.
494    - zakatable: A boolean indicating whether the account is subject to zakat.
495    """
496    balance: int
497    created: Timestamp
498    name: str = ''
499    box: dict[Timestamp, Box] = dataclasses.field(default_factory=dict)
500    count: int = dataclasses.field(default_factory=factory_value(0))
501    log: dict[Timestamp, Log] = dataclasses.field(default_factory=dict)
502    hide: bool = dataclasses.field(default_factory=factory_value(False))
503    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):
506@dataclasses.dataclass
507class Exchange(StrictDataclass):
508    """
509    Represents an exchange rate and related information.
510
511    Attributes:
512    - rate: The exchange rate (optional).
513    - description: A description of the exchange (optional).
514    - time: The timestamp of the exchange (optional).
515    """
516    rate: Optional[float] = None
517    description: Optional[str] = None
518    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):
521@dataclasses.dataclass
522class History(StrictDataclass):
523    """
524    Represents a history entry for an account action.
525
526    Attributes:
527    - action: The action performed.
528    - account: The ID of the account (optional).
529    - ref: An optional timestamp reference.
530    - file: An optional timestamp for a file.
531    - key: An optional key.
532    - value: An optional value.
533    - math: An optional math operation.
534    """
535    action: Action
536    account: Optional[AccountID]
537    ref: Optional[Timestamp]
538    file: Optional[Timestamp]
539    key: Optional[str]
540    value: Optional[any] # !!!
541    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):
616@dataclasses.dataclass
617class Vault(StrictDataclass):
618    """
619    Represents a vault containing accounts, exchanges, and history.
620
621    Attributes:
622    - account: A dictionary mapping account IDs to Account objects.
623    - exchange: A dictionary mapping account IDs to dictionaries of timestamps and Exchange objects.
624    - history: A dictionary mapping timestamps to dictionaries of History objects.
625    - lock: An optional timestamp for a lock.
626    - report: A dictionary mapping timestamps to tuples.
627    - cache: A Cache object containing cached Zakat-related data.
628    """
629    account: dict[AccountID, Account] = dataclasses.field(default_factory=dict)
630    exchange: dict[AccountID, dict[Timestamp, Exchange]] = dataclasses.field(default_factory=dict)
631    history: dict[Timestamp, dict[Timestamp, History]] = dataclasses.field(default_factory=dict)
632    lock: Optional[Timestamp] = None
633    report: dict[Timestamp, ZakatReport] = dataclasses.field(default_factory=dict)
634    cache: Cache = dataclasses.field(default_factory=Cache)

Represents a vault containing accounts, exchanges, and history.

Attributes:

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

A list of SubtractAge objects.

@dataclasses.dataclass
class SubtractReport(zakat.StrictDataclass):
688@dataclasses.dataclass
689class SubtractReport(StrictDataclass):
690    """
691    Represents a report of age subtractions.
692
693    Attributes:
694    - log_ref: The timestamp reference for the log.
695    - ages: A list of SubtractAge objects.
696    """
697    log_ref: Timestamp
698    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):
701@dataclasses.dataclass
702class TransferTime(StrictDataclass):
703    """
704    Represents a transfer time.
705
706    Attributes:
707    - box_ref: The timestamp reference for the box.
708    - log_ref: The timestamp reference for the log.
709    """
710    box_ref: Timestamp
711    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]):
714@dataclasses.dataclass
715class TransferTimes(StrictDataclass, list[TransferTime]):
716    """A list of TransferTime objects."""
717    pass

A list of TransferTime objects.

@dataclasses.dataclass
class TransferRecord(zakat.StrictDataclass):
720@dataclasses.dataclass
721class TransferRecord(StrictDataclass):
722    """
723    Represents a transfer record.
724
725    Attributes:
726    - box_ref: The timestamp reference for the box.
727    - times: A list of TransferTime objects.
728    """
729    box_ref: Timestamp
730    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]):
733class TransferReport(StrictDataclass, list[TransferRecord]):
734    """A list of TransferRecord objects."""
735    pass

A list of TransferRecord objects.

@dataclasses.dataclass
class BoxPlan(zakat.StrictDataclass):
544@dataclasses.dataclass
545class BoxPlan(StrictDataclass):
546    """
547    Represents a plan for a box.
548
549    Attributes:
550    - box: The Box object.
551    - log: The Log object.
552    - exchange: The Exchange object.
553    - below_nisab: A boolean indicating whether the value is below nisab.
554    - total: The total value.
555    - count: The count.
556    - ref: The timestamp reference for related Box & Log.
557    """
558    box: Box
559    log: Log
560    exchange: Exchange
561    below_nisab: bool
562    total: float
563    count: int
564    ref: Timestamp

Represents a plan for a box.

Attributes:

  • box: The Box object.
  • log: The Log object.
  • exchange: The Exchange object.
  • below_nisab: A boolean indicating whether the value is below nisab.
  • total: The total value.
  • count: The count.
  • ref: The timestamp reference for related Box & Log.
BoxPlan( box: Box, log: Log, exchange: Exchange, below_nisab: bool, total: float, count: int, ref: Timestamp)
box: Box
log: Log
exchange: Exchange
below_nisab: bool
total: float
count: int
ref: Timestamp
@dataclasses.dataclass
class ZakatSummary(zakat.StrictDataclass):
567@dataclasses.dataclass
568class ZakatSummary(StrictDataclass):
569    """
570    Summarizes key financial figures for a Zakat calculation.
571
572    Attributes:
573    - total_wealth (int): The total wealth collected from all rest of transactions.
574    - num_wealth_items (int): The number of individual transactions contributing to the total wealth.
575    - num_zakatable_items (int): The number of transactions subject to Zakat.
576    - total_zakatable_amount (int): The total value of all transactions subject to Zakat.
577    - total_zakat_due (int): The calculated amount of Zakat payable.
578    """
579    total_wealth: int = 0
580    num_wealth_items: int = 0
581    num_zakatable_items: int = 0
582    total_zakatable_amount: int = 0
583    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):
586@dataclasses.dataclass
587class ZakatReport(StrictDataclass):
588    """
589    Represents a Zakat report containing the calculation summary, plan, and parameters.
590
591    Attributes:
592    - created: The timestamp when the report was created.
593    - valid: A boolean indicating whether the Zakat is available.
594    - summary: The ZakatSummary object.
595    - plan: A dictionary mapping account IDs to lists of BoxPlan objects.
596    - parameters: A dictionary holding the input parameters used during the Zakat calculation.
597    """
598    created: Timestamp
599    valid: bool
600    summary: ZakatSummary
601    plan: dict[AccountID, list[BoxPlan]]
602    parameters: dict

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

Attributes:

  • created: The timestamp when the report was created.
  • valid: A boolean indicating whether the Zakat is available.
  • summary: The ZakatSummary object.
  • plan: A dictionary mapping account IDs to lists of BoxPlan objects.
  • parameters: A dictionary holding the input parameters used during the Zakat calculation.
ZakatReport( created: Timestamp, valid: bool, summary: ZakatSummary, plan: dict[AccountID, list[BoxPlan]], parameters: dict)
created: Timestamp
valid: bool
summary: ZakatSummary
plan: dict[AccountID, list[BoxPlan]]
parameters: dict
def test(path: Optional[str] = None, debug: bool = False):
5356def test(path: Optional[str] = None, debug: bool = False):
5357    """
5358    Executes a test suite for the ZakatTracker.
5359
5360    This function initializes a ZakatTracker instance, optionally using a specified
5361    database path or a temporary directory. It then runs the test suite and, if debug
5362    mode is enabled, prints detailed test results and execution time.
5363
5364    Parameters:
5365    - path (str, optional): The path to the ZakatTracker database. If None, a
5366                            temporary directory is created. Defaults to None.
5367    - debug (bool, optional): Enables debug mode, which prints detailed test
5368                            results and execution time. Defaults to False.
5369
5370    Returns:
5371    None. The function asserts the result of the ZakatTracker's test suite.
5372
5373    Raises:
5374    - AssertionError: If the ZakatTracker's test suite fails.
5375
5376    Examples:
5377    - `test()` Runs tests using a temporary database.
5378    - `test(debug=True)` Runs the test suite in debug mode with a temporary directory.
5379    - `test(path="/path/to/my/db")` Runs tests using a specified database path.
5380    - `test(path="/path/to/my/db", debug=False)` Runs test suite with specified path.
5381    """
5382    no_path = path is None
5383    if no_path:
5384        path = tempfile.mkdtemp()
5385        print(f"Random database path {path}")
5386    if os.path.exists(path):
5387        shutil.rmtree(path)
5388    assert ZakatTracker(':memory:').memory_mode()
5389    ledger = ZakatTracker(
5390        db_path=path,
5391        history_mode=True,
5392    )
5393    start = time.time_ns()
5394    assert not ledger.memory_mode()
5395    assert ledger.test(debug=debug)
5396    if no_path and os.path.exists(path):
5397        shutil.rmtree(path)
5398    if debug:
5399        print('#########################')
5400        print('######## TEST DONE ########')
5401        print('#########################')
5402        print(Time.duration_from_nanoseconds(time.time_ns() - start))
5403        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):
110@enum.unique
111class Action(enum.Enum):
112    """
113    Enumeration representing various actions that can be performed.
114
115    Members:
116    - CREATE: Represents the creation action ('CREATE').
117    - NAME: Represents the renaming action ('NAME').
118    - TRACK: Represents the tracking action ('TRACK').
119    - LOG: Represents the logging action ('LOG').
120    - SUBTRACT: Represents the subtract action ('SUBTRACT').
121    - ADD_FILE: Represents the action of adding a file ('ADD_FILE').
122    - REMOVE_FILE: Represents the action of removing a file ('REMOVE_FILE').
123    - BOX_TRANSFER: Represents the action of transferring a box ('BOX_TRANSFER').
124    - EXCHANGE: Represents the exchange action ('EXCHANGE').
125    - REPORT: Represents the reporting action ('REPORT').
126    - ZAKAT: Represents a Zakat related action ('ZAKAT').
127    """
128    CREATE = 'CREATE'
129    NAME = 'NAME'
130    TRACK = 'TRACK'
131    LOG = 'LOG'
132    SUBTRACT = 'SUBTRACT'
133    ADD_FILE = 'ADD_FILE'
134    REMOVE_FILE = 'REMOVE_FILE'
135    BOX_TRANSFER = 'BOX_TRANSFER'
136    EXCHANGE = 'EXCHANGE'
137    REPORT = 'REPORT'
138    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):
874class JSONEncoder(json.JSONEncoder):
875    """
876    Custom JSON encoder to handle specific object types.
877
878    This encoder overrides the default `default` method to serialize:
879    - `Action` and `MathOperation` enums as their member names.
880    - `decimal.Decimal` instances as floats.
881
882    Example:
883    ```bash
884    >>> json.dumps(Action.CREATE, cls=JSONEncoder)
885    'CREATE'
886    >>> json.dumps(decimal.Decimal('10.5'), cls=JSONEncoder)
887    '10.5'
888    ```
889    """
890    def default(self, o):
891        """
892        Overrides the default `default` method to serialize specific object types.
893
894        Parameters:
895        - o: The object to serialize.
896
897        Returns:
898        - The serialized object.
899        """
900        if isinstance(o, (Action, MathOperation)):
901            return o.name  # Serialize as the enum member's name
902        if isinstance(o, decimal.Decimal):
903            return float(o)
904        if isinstance(o, Exception):
905            return str(o)
906        if isinstance(o, Vault) or isinstance(o, ImportReport):
907            return dataclasses.asdict(o)
908        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):
890    def default(self, o):
891        """
892        Overrides the default `default` method to serialize specific object types.
893
894        Parameters:
895        - o: The object to serialize.
896
897        Returns:
898        - The serialized object.
899        """
900        if isinstance(o, (Action, MathOperation)):
901            return o.name  # Serialize as the enum member's name
902        if isinstance(o, decimal.Decimal):
903            return float(o)
904        if isinstance(o, Exception):
905            return str(o)
906        if isinstance(o, Vault) or isinstance(o, ImportReport):
907            return dataclasses.asdict(o)
908        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):
911class JSONDecoder(json.JSONDecoder):
912    """
913    Custom JSON decoder to handle specific object types.
914
915    This decoder overrides the `object_hook` method to deserialize:
916    - Strings representing enum member names back to their respective enum values.
917    - Floats back to `decimal.Decimal` instances.
918
919    Example:
920    ```bash
921    >>> json.loads('{"action": "CREATE"}', cls=JSONDecoder)
922    {'action': <Action.CREATE: 1>}
923    >>> json.loads('{"value": 10.5}', cls=JSONDecoder)
924    {'value': Decimal('10.5')}
925    ```
926    """
927    def object_hook(self, obj):
928        """
929        Overrides the default `object_hook` method to deserialize specific object types.
930
931        Parameters:
932        - obj: The object to deserialize.
933
934        Returns:
935        - The deserialized object.
936        """
937        if isinstance(obj, str) and obj in Action.__members__:
938            return Action[obj]
939        if isinstance(obj, str) and obj in MathOperation.__members__:
940            return MathOperation[obj]
941        if isinstance(obj, float):
942            return decimal.Decimal(str(obj))
943        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):
927    def object_hook(self, obj):
928        """
929        Overrides the default `object_hook` method to deserialize specific object types.
930
931        Parameters:
932        - obj: The object to deserialize.
933
934        Returns:
935        - The deserialized object.
936        """
937        if isinstance(obj, str) and obj in Action.__members__:
938            return Action[obj]
939        if isinstance(obj, str) and obj in MathOperation.__members__:
940            return MathOperation[obj]
941        if isinstance(obj, float):
942            return decimal.Decimal(str(obj))
943        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):
141@enum.unique
142class MathOperation(enum.Enum):
143    """
144    Enumeration representing mathematical operations.
145
146    Members:
147    - ADDITION: Represents the addition operation ('ADDITION').
148    - EQUAL: Represents the equality operation ('EQUAL').
149    - SUBTRACTION: Represents the subtraction operation ('SUBTRACTION').
150    """
151    ADDITION = 'ADDITION'
152    EQUAL = 'EQUAL'
153    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):
 87@enum.unique
 88class WeekDay(enum.Enum):
 89    """
 90    Enumeration representing the days of the week.
 91
 92    Members:
 93    - MONDAY: Represents Monday (0).
 94    - TUESDAY: Represents Tuesday (1).
 95    - WEDNESDAY: Represents Wednesday (2).
 96    - THURSDAY: Represents Thursday (3).
 97    - FRIDAY: Represents Friday (4).
 98    - SATURDAY: Represents Saturday (5).
 99    - SUNDAY: Represents Sunday (6).
100    """
101    MONDAY = 0
102    TUESDAY = 1
103    WEDNESDAY = 2
104    THURSDAY = 3
105    FRIDAY = 4
106    SATURDAY = 5
107    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:
360@dataclasses.dataclass
361class StrictDataclass:
362    """A dataclass that prevents setting non-existent attributes."""
363    def __setattr__(self, name: str, value: any) -> None:
364        _check_attribute(self, name, value)

A dataclass that prevents setting non-existent attributes.

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