Offer defaults from the latest invoice PDF and add days-off deduction
Parse existing Invoice_*.pdf files (PyMuPDF) for the invoice number and "Work performed" period, then default the next period to the 14 days after the latest one, the number to <month><letter>/<year> with the letter incrementing within the month, and the amount to 7250 EUR. Also add an optional days-off deduction at the derived daily rate and ask before overwriting an existing PDF. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
227
invoicer.py
227
invoicer.py
@@ -4,6 +4,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta
|
||||
@@ -64,12 +65,110 @@ LIGHT_GRAY = HexColor("#EAEAEA")
|
||||
TABLE_HEAD = HexColor("#4A4A4A")
|
||||
|
||||
|
||||
WORKDAYS_PER_WEEK = 5
|
||||
PERIOD_DAYS = 14
|
||||
DEFAULT_AMOUNT = 7250.0
|
||||
INVOICE_GLOB = "Invoice_*.pdf"
|
||||
|
||||
NUMBER_RE = re.compile(r"#\s*(\d+)([a-z])/(\d{4})")
|
||||
PERIOD_RE = re.compile(
|
||||
r"Work performed from\s+(\d{1,2}\.\d{1,2}\.\d{4})\.?\s*-\s*(\d{1,2}\.\d{1,2}\.\d{4})\.?"
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PastInvoice:
|
||||
path: Path
|
||||
month: int
|
||||
letter: str
|
||||
year: int
|
||||
period_start: date
|
||||
period_end: date
|
||||
|
||||
@property
|
||||
def number(self) -> str:
|
||||
return f"{self.month}{self.letter}/{self.year}"
|
||||
|
||||
|
||||
def read_pdf_text(path: Path) -> str:
|
||||
import fitz # PyMuPDF
|
||||
|
||||
with fitz.open(str(path)) as doc:
|
||||
return "\n".join(page.get_text() for page in doc)
|
||||
|
||||
|
||||
def parse_invoice_pdf(path: Path) -> PastInvoice | None:
|
||||
try:
|
||||
text = read_pdf_text(path)
|
||||
except Exception:
|
||||
return None
|
||||
num = NUMBER_RE.search(text)
|
||||
per = PERIOD_RE.search(text)
|
||||
if not num or not per:
|
||||
return None
|
||||
try:
|
||||
start = parse_date(per.group(1))
|
||||
end = parse_date(per.group(2))
|
||||
except ValueError:
|
||||
return None
|
||||
return PastInvoice(
|
||||
path=path,
|
||||
month=int(num.group(1)),
|
||||
letter=num.group(2),
|
||||
year=int(num.group(3)),
|
||||
period_start=start,
|
||||
period_end=end,
|
||||
)
|
||||
|
||||
|
||||
def load_past_invoices(folder: Path) -> list[PastInvoice]:
|
||||
invoices = [parse_invoice_pdf(p) for p in folder.glob(INVOICE_GLOB)]
|
||||
return sorted((i for i in invoices if i), key=lambda i: (i.period_end, i.path.stat().st_mtime))
|
||||
|
||||
|
||||
def latest_invoice(invoices: list[PastInvoice]) -> PastInvoice | None:
|
||||
return invoices[-1] if invoices else None
|
||||
|
||||
|
||||
def next_invoice_number(invoices: list[PastInvoice], period_end: date) -> str:
|
||||
"""Number = <month of period end><letter>/<year>; letter increments within the month."""
|
||||
letters = [
|
||||
i.letter
|
||||
for i in invoices
|
||||
if i.month == period_end.month and i.year == period_end.year
|
||||
]
|
||||
letter = chr(ord(max(letters)) + 1) if letters else "a"
|
||||
return f"{period_end.month}{letter}/{period_end.year}"
|
||||
|
||||
|
||||
@dataclass
|
||||
class InvoiceInputs:
|
||||
number: str
|
||||
period_start: date
|
||||
period_end: date
|
||||
total_amount: float
|
||||
days_off: int = 0
|
||||
|
||||
@property
|
||||
def weeks(self) -> int:
|
||||
days = (self.period_end - self.period_start).days + 1
|
||||
return max(1, round(days / 7))
|
||||
|
||||
@property
|
||||
def workdays(self) -> int:
|
||||
return self.weeks * WORKDAYS_PER_WEEK
|
||||
|
||||
@property
|
||||
def daily_rate(self) -> float:
|
||||
return round(self.total_amount / self.workdays, 2)
|
||||
|
||||
@property
|
||||
def days_off_amount(self) -> float:
|
||||
return round(self.days_off * self.daily_rate, 2)
|
||||
|
||||
@property
|
||||
def net_total(self) -> float:
|
||||
return round(self.total_amount - self.days_off_amount, 2)
|
||||
|
||||
@property
|
||||
def issue_date(self) -> date:
|
||||
@@ -86,9 +185,15 @@ class InvoiceInputs:
|
||||
return d
|
||||
|
||||
|
||||
def prompt_str(label: str, validator=None) -> str:
|
||||
def with_default(label: str, default: str | None) -> str:
|
||||
return f"{label} [{default}]: " if default is not None else f"{label}: "
|
||||
|
||||
|
||||
def prompt_str(label: str, validator=None, default: str | None = None) -> str:
|
||||
while True:
|
||||
value = input(f"{label}: ").strip()
|
||||
value = input(with_default(label, default)).strip()
|
||||
if not value and default is not None:
|
||||
return default
|
||||
if not value:
|
||||
print(" Value required.")
|
||||
continue
|
||||
@@ -109,18 +214,23 @@ def parse_date(s: str) -> date:
|
||||
raise ValueError("expected YYYY-MM-DD, DD.MM.YYYY, or DD/MM/YYYY")
|
||||
|
||||
|
||||
def prompt_date(label: str) -> date:
|
||||
def prompt_date(label: str, default: date | None = None) -> date:
|
||||
while True:
|
||||
raw = input(f"{label}: ").strip()
|
||||
raw = input(with_default(label, default.isoformat() if default else None)).strip()
|
||||
if not raw and default is not None:
|
||||
return default
|
||||
try:
|
||||
return parse_date(raw)
|
||||
except ValueError as e:
|
||||
print(f" {e}")
|
||||
|
||||
|
||||
def prompt_amount(label: str) -> float:
|
||||
def prompt_amount(label: str, default: float | None = None) -> float:
|
||||
while True:
|
||||
raw = input(f"{label}: ").strip().replace(",", ".").replace("€", "").replace(" ", "")
|
||||
shown = f"{default:g}" if default is not None else None
|
||||
raw = input(with_default(label, shown)).strip().replace(",", ".").replace("€", "").replace(" ", "")
|
||||
if not raw and default is not None:
|
||||
return default
|
||||
try:
|
||||
value = float(raw)
|
||||
if value <= 0:
|
||||
@@ -130,20 +240,60 @@ def prompt_amount(label: str) -> float:
|
||||
print(" Enter a positive number, e.g. 7250 or 7250.00")
|
||||
|
||||
|
||||
def gather_inputs() -> InvoiceInputs:
|
||||
def prompt_int(label: str, max_value: int | None = None, default: int | None = None) -> int:
|
||||
while True:
|
||||
raw = input(with_default(label, str(default) if default is not None else None)).strip()
|
||||
if not raw and default is not None:
|
||||
return default
|
||||
try:
|
||||
value = int(raw)
|
||||
if value < 0:
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
print(" Enter a whole number, e.g. 0 or 2")
|
||||
continue
|
||||
if max_value is not None and value > max_value:
|
||||
print(f" Must be between 0 and {max_value}.")
|
||||
continue
|
||||
return value
|
||||
|
||||
|
||||
def gather_inputs(folder: Path) -> InvoiceInputs:
|
||||
print("KBR4 invoice generator — Audact AI Ltd")
|
||||
print("-" * 40)
|
||||
number = prompt_str("Invoice number (e.g. 1/2026)")
|
||||
past = load_past_invoices(folder)
|
||||
last = latest_invoice(past)
|
||||
default_start = default_end = None
|
||||
if last:
|
||||
print(
|
||||
f"Last invoice: {last.number} ({last.path.name}), "
|
||||
f"work performed {fmt_date_dotted(last.period_start)} - {fmt_date_dotted(last.period_end)}"
|
||||
)
|
||||
default_start = last.period_end + timedelta(days=1)
|
||||
default_end = default_start + timedelta(days=PERIOD_DAYS - 1)
|
||||
else:
|
||||
print(f"No previous {INVOICE_GLOB} found in {folder}; no period/number defaults.")
|
||||
print("Invoice period (two-week range, in arrears):")
|
||||
start = prompt_date(" Period start (YYYY-MM-DD)")
|
||||
start = prompt_date(" Period start (YYYY-MM-DD)", default=default_start)
|
||||
if default_end is not None and start != default_start:
|
||||
default_end = start + timedelta(days=PERIOD_DAYS - 1)
|
||||
while True:
|
||||
end = prompt_date(" Period end (YYYY-MM-DD)")
|
||||
end = prompt_date(" Period end (YYYY-MM-DD)", default=default_end)
|
||||
if end < start:
|
||||
print(" End must be on or after start.")
|
||||
continue
|
||||
break
|
||||
total = prompt_amount("Total amount in EUR")
|
||||
return InvoiceInputs(number=number, period_start=start, period_end=end, total_amount=total)
|
||||
number = prompt_str("Invoice number (e.g. 1a/2026)", default=next_invoice_number(past, end))
|
||||
total = prompt_amount("Total amount in EUR", default=DEFAULT_AMOUNT)
|
||||
inv = InvoiceInputs(number=number, period_start=start, period_end=end, total_amount=total)
|
||||
print(
|
||||
f"Period spans {inv.weeks} week(s) = {inv.workdays} workdays "
|
||||
f"at {fmt_money(inv.daily_rate)}/day."
|
||||
)
|
||||
inv.days_off = prompt_int(
|
||||
f"Days off in this period (0-{inv.workdays})", max_value=inv.workdays, default=0
|
||||
)
|
||||
return inv
|
||||
|
||||
|
||||
def fmt_money(amount: float) -> str:
|
||||
@@ -250,7 +400,7 @@ def render_pdf(inv: InvoiceInputs, output_path: Path) -> None:
|
||||
c.setFillColor(black)
|
||||
c.setFont("Helvetica-Bold", 11)
|
||||
c.drawRightString(meta_label_x, meta_y, "Balance Due:")
|
||||
c.drawRightString(meta_value_x, meta_y, fmt_money(inv.total_amount))
|
||||
c.drawRightString(meta_value_x, meta_y, fmt_money(inv.net_total))
|
||||
|
||||
table_top = min(bill_y, bal_box_y) - 30
|
||||
row_h = 22
|
||||
@@ -270,7 +420,7 @@ def render_pdf(inv: InvoiceInputs, output_path: Path) -> None:
|
||||
c.drawRightString(rate_right, text_y, "Rate")
|
||||
c.drawRightString(amount_right, text_y, "Amount")
|
||||
|
||||
weeks = 2
|
||||
weeks = inv.weeks
|
||||
rate = round(inv.total_amount / weeks, 2)
|
||||
amount = inv.total_amount
|
||||
|
||||
@@ -279,7 +429,7 @@ def render_pdf(inv: InvoiceInputs, output_path: Path) -> None:
|
||||
c.setFont("Helvetica-Bold", 10)
|
||||
c.drawString(item_x, row_y, LINE_ITEM_DESCRIPTION_LINES[0])
|
||||
c.setFont("Helvetica", 10)
|
||||
c.drawRightString(qty_right, row_y, f"{weeks} weeks")
|
||||
c.drawRightString(qty_right, row_y, f"{weeks} week{'s' if weeks != 1 else ''}")
|
||||
c.drawRightString(rate_right, row_y, fmt_money(rate))
|
||||
c.drawRightString(amount_right, row_y, fmt_money(amount))
|
||||
if len(LINE_ITEM_DESCRIPTION_LINES) > 1:
|
||||
@@ -289,6 +439,22 @@ def render_pdf(inv: InvoiceInputs, output_path: Path) -> None:
|
||||
row_y -= 13
|
||||
c.drawString(item_x, row_y, extra)
|
||||
|
||||
if inv.days_off > 0:
|
||||
row_y -= 24
|
||||
day_word = "day" if inv.days_off == 1 else "days"
|
||||
c.setFillColor(black)
|
||||
c.setFont("Helvetica-Bold", 10)
|
||||
c.drawString(item_x, row_y, "Days off")
|
||||
c.setFont("Helvetica", 10)
|
||||
c.setFillColor(GRAY)
|
||||
c.drawString(item_x, row_y - 13, "Deduction for time not worked")
|
||||
c.setFillColor(black)
|
||||
c.setFont("Helvetica", 10)
|
||||
c.drawRightString(qty_right, row_y, f"{inv.days_off} {day_word}")
|
||||
c.drawRightString(rate_right, row_y, fmt_money(inv.daily_rate))
|
||||
c.drawRightString(amount_right, row_y, f"-{fmt_money(inv.days_off_amount)}")
|
||||
row_y -= 13
|
||||
|
||||
totals_y = row_y - 50
|
||||
label_x = margin_r - 110
|
||||
value_x = margin_r
|
||||
@@ -297,6 +463,12 @@ def render_pdf(inv: InvoiceInputs, output_path: Path) -> None:
|
||||
c.drawRightString(label_x, totals_y, "Subtotal:")
|
||||
c.setFillColor(black)
|
||||
c.drawRightString(value_x, totals_y, fmt_money(amount))
|
||||
if inv.days_off > 0:
|
||||
totals_y -= 16
|
||||
c.setFillColor(GRAY)
|
||||
c.drawRightString(label_x, totals_y, "Days off:")
|
||||
c.setFillColor(black)
|
||||
c.drawRightString(value_x, totals_y, f"-{fmt_money(inv.days_off_amount)}")
|
||||
totals_y -= 16
|
||||
c.setFillColor(GRAY)
|
||||
c.drawRightString(label_x, totals_y, "Tax (0%):")
|
||||
@@ -307,7 +479,7 @@ def render_pdf(inv: InvoiceInputs, output_path: Path) -> None:
|
||||
c.drawRightString(label_x, totals_y, "Total:")
|
||||
c.setFillColor(black)
|
||||
c.setFont("Helvetica-Bold", 10.5)
|
||||
c.drawRightString(value_x, totals_y, fmt_money(amount))
|
||||
c.drawRightString(value_x, totals_y, fmt_money(inv.net_total))
|
||||
|
||||
notes_y = totals_y - 50
|
||||
c.setFillColor(GRAY)
|
||||
@@ -321,6 +493,14 @@ def render_pdf(inv: InvoiceInputs, output_path: Path) -> None:
|
||||
notes_y,
|
||||
f"Work performed from {fmt_date_dotted(inv.period_start)} - {fmt_date_dotted(inv.period_end)}",
|
||||
)
|
||||
if inv.days_off > 0:
|
||||
day_word = "day" if inv.days_off == 1 else "days"
|
||||
notes_y -= 13
|
||||
c.drawString(
|
||||
margin_l,
|
||||
notes_y,
|
||||
f"{inv.days_off} {day_word} off deducted at {fmt_money(inv.daily_rate)}/day.",
|
||||
)
|
||||
notes_y -= 13
|
||||
c.drawString(
|
||||
margin_l,
|
||||
@@ -367,19 +547,28 @@ def render_pdf(inv: InvoiceInputs, output_path: Path) -> None:
|
||||
|
||||
|
||||
def main() -> int:
|
||||
folder = Path.cwd()
|
||||
try:
|
||||
inv = gather_inputs()
|
||||
inv = gather_inputs(folder)
|
||||
except (KeyboardInterrupt, EOFError):
|
||||
print()
|
||||
return 130
|
||||
|
||||
output_path = Path.cwd() / safe_filename(inv.number)
|
||||
output_path = folder / safe_filename(inv.number)
|
||||
if output_path.exists():
|
||||
answer = input(f"{output_path.name} already exists. Overwrite? [y/N]: ").strip().lower()
|
||||
if answer not in ("y", "yes"):
|
||||
print("Aborted.")
|
||||
return 1
|
||||
render_pdf(inv, output_path)
|
||||
print()
|
||||
print(f"Wrote {output_path}")
|
||||
print(f" Issue date: {fmt_date_long(inv.issue_date)}")
|
||||
print(f" Due date: {fmt_date_long(inv.due_date)}")
|
||||
print(f" Total: {fmt_money(inv.total_amount)}")
|
||||
print(f" Subtotal: {fmt_money(inv.total_amount)}")
|
||||
if inv.days_off > 0:
|
||||
print(f" Days off: {inv.days_off} (-{fmt_money(inv.days_off_amount)})")
|
||||
print(f" Total: {fmt_money(inv.net_total)}")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user