feat: payroll contract schema and CRUD
The one entity payroll needs: a standing instruction to pay an employee a fixed amount, at a fixed cadence, from a given start date, a given number of times. Two design decisions worth reviewing here, both documented in docs/data-model.md: - Schedule position is `periods_done` (a counter), not a stored `next_run_at`. Every payday is recomputed as occurrence(start_date, frequency, n), so a late tick cannot make the schedule drift, and a contract anchored on the 31st pays 28 Feb then 31 Mar rather than being permanently pinned to the 28th. - `periods_done` counts periods *consumed* (paid or deliberately skipped), not periods successfully paid. A failed payout leaves it untouched so the next tick retries that payday instead of dropping it. There is no employee table — an employee is an LNbits account. Only `employee_username` is copied, and only as a display label so history stays readable after a rename; authorisation always goes through `employee_id`. Ordinary migrations, not the migrations_fork.py split: this is an aiolabs-original extension, so there is no upstream migrations.py to stay byte-identical with. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018jy52j9GRZ6XKa1Zt21LLj
This commit is contained in:
parent
a211a8ab8b
commit
f253b51aa9
4 changed files with 424 additions and 0 deletions
89
crud.py
Normal file
89
crud.py
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
"""Payroll CRUD.
|
||||
|
||||
Thin by design: the interesting logic (when a period falls due, whether a
|
||||
payout may run) lives in services.py, and this module only owns row
|
||||
lifecycle. The one rule enforced here is that `updated_at` is stamped on
|
||||
every write, so "when did this contract last change" is answerable without
|
||||
a separate audit trail.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from lnbits.db import Database
|
||||
from lnbits.helpers import urlsafe_short_hash
|
||||
|
||||
from .models import Contract, ContractStatus, CreateContract
|
||||
|
||||
db = Database("ext_payroll")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Contracts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def create_contract(
|
||||
data: CreateContract, employee_username: str = ""
|
||||
) -> Contract:
|
||||
contract = Contract(
|
||||
**data.dict(),
|
||||
id=urlsafe_short_hash()[:8],
|
||||
employee_username=employee_username,
|
||||
)
|
||||
await db.insert("payroll.contracts", contract)
|
||||
return contract
|
||||
|
||||
|
||||
async def get_contract(contract_id: str) -> Contract | None:
|
||||
return await db.fetchone(
|
||||
"SELECT * FROM payroll.contracts WHERE id = :id",
|
||||
{"id": contract_id},
|
||||
Contract,
|
||||
)
|
||||
|
||||
|
||||
async def get_contracts() -> list[Contract]:
|
||||
return await db.fetchall(
|
||||
"SELECT * FROM payroll.contracts ORDER BY created_at DESC", model=Contract
|
||||
)
|
||||
|
||||
|
||||
async def get_contracts_by_status(status: ContractStatus) -> list[Contract]:
|
||||
return await db.fetchall(
|
||||
"SELECT * FROM payroll.contracts WHERE status = :status ORDER BY created_at",
|
||||
{"status": status.value},
|
||||
Contract,
|
||||
)
|
||||
|
||||
|
||||
async def get_contracts_for_employee(employee_id: str) -> list[Contract]:
|
||||
return await db.fetchall(
|
||||
"SELECT * FROM payroll.contracts WHERE employee_id = :eid "
|
||||
"ORDER BY created_at DESC",
|
||||
{"eid": employee_id},
|
||||
Contract,
|
||||
)
|
||||
|
||||
|
||||
async def get_contracts_for_wallet(wallet_id: str) -> list[Contract]:
|
||||
"""Contracts paying *into* a given wallet — the employee-side view, keyed
|
||||
on the wallet whose key authenticated the request rather than on an
|
||||
account id."""
|
||||
return await db.fetchall(
|
||||
"SELECT * FROM payroll.contracts WHERE employee_wallet = :wid "
|
||||
"ORDER BY created_at DESC",
|
||||
{"wid": wallet_id},
|
||||
Contract,
|
||||
)
|
||||
|
||||
|
||||
async def update_contract(contract: Contract) -> Contract:
|
||||
contract.updated_at = datetime.now(timezone.utc)
|
||||
await db.update("payroll.contracts", contract)
|
||||
return contract
|
||||
|
||||
|
||||
async def delete_contract(contract_id: str) -> None:
|
||||
await db.execute(
|
||||
"DELETE FROM payroll.contracts WHERE id = :id", {"id": contract_id}
|
||||
)
|
||||
Loading…
Add table
Add a link
Reference in a new issue