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:
Padreug 2026-08-31 13:38:25 +02:00
commit f253b51aa9
4 changed files with 424 additions and 0 deletions

89
crud.py Normal file
View 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}
)