feat(reporting,invoices): scaffold reporting + invoicing extensions

- Add data models:
  - Reporting: SavedReportView, ReportEmailSchedule
  - Currency/FX: Currency, ExchangeRate
  - Tax: TaxRule (flexible rules with date ranges; client/project scoping)
  - Invoices: InvoiceTemplate, Payment, CreditNote, InvoiceReminderSchedule
- Extend Invoice:
  - Add currency_code and template_id
  - Add relationships: payments, credits, reminder_schedules
  - Compute outstanding by subtracting credits; helper to apply matching TaxRule
- Register new models in app/models/__init__.py
- DB: add Alembic migration 017 to create new tables and alter invoices

Notes:
- Requires database migration (alembic upgrade head).
- Follow-ups: FX fetching + scheduler, report builder UI/CRUD, utilization dashboard,
  email schedules/reminders, invoice themes in UI, partial payments and credit notes in routes/templates
This commit is contained in:
Dries Peeters
2025-10-06 13:51:24 +02:00
parent b6c0a79ffc
commit db82068dfd
8 changed files with 473 additions and 2 deletions

View File

@@ -4,6 +4,11 @@ from .time_entry import TimeEntry
from .task import Task
from .settings import Settings
from .invoice import Invoice, InvoiceItem
from .invoice_template import InvoiceTemplate
from .currency import Currency, ExchangeRate
from .tax_rule import TaxRule
from .payments import Payment, CreditNote, InvoiceReminderSchedule
from .reporting import SavedReportView, ReportEmailSchedule
from .client import Client
from .task_activity import TaskActivity
from .comment import Comment
@@ -14,5 +19,7 @@ from .saved_filter import SavedFilter
__all__ = [
'User', 'Project', 'TimeEntry', 'Task', 'Settings', 'Invoice', 'InvoiceItem', 'Client', 'TaskActivity', 'Comment',
'FocusSession', 'RecurringBlock', 'RateOverride', 'SavedFilter'
'FocusSession', 'RecurringBlock', 'RateOverride', 'SavedFilter',
'InvoiceTemplate', 'Currency', 'ExchangeRate', 'TaxRule', 'Payment', 'CreditNote', 'InvoiceReminderSchedule',
'SavedReportView', 'ReportEmailSchedule'
]

44
app/models/currency.py Normal file
View File

@@ -0,0 +1,44 @@
from datetime import datetime
from app import db
class Currency(db.Model):
"""Supported currencies and display metadata."""
__tablename__ = 'currencies'
code = db.Column(db.String(3), primary_key=True) # e.g., EUR, USD
name = db.Column(db.String(64), nullable=False)
symbol = db.Column(db.String(8), nullable=True) # e.g., €, $
decimal_places = db.Column(db.Integer, default=2, nullable=False)
is_active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
def __repr__(self):
return f"<Currency {self.code}>"
class ExchangeRate(db.Model):
"""Daily exchange rates between currency pairs."""
__tablename__ = 'exchange_rates'
id = db.Column(db.Integer, primary_key=True)
base_code = db.Column(db.String(3), db.ForeignKey('currencies.code'), nullable=False, index=True)
quote_code = db.Column(db.String(3), db.ForeignKey('currencies.code'), nullable=False, index=True)
rate = db.Column(db.Numeric(18, 8), nullable=False)
date = db.Column(db.Date, nullable=False, index=True)
source = db.Column(db.String(50), nullable=True) # e.g., ECB, exchangerate.host
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
__table_args__ = (
db.UniqueConstraint('base_code', 'quote_code', 'date', name='uq_exchange_rate_day'),
)
def __repr__(self):
return f"<ExchangeRate {self.base_code}/{self.quote_code} {self.date} {self.rate}>"

View File

@@ -26,6 +26,8 @@ class Invoice(db.Model):
tax_rate = db.Column(db.Numeric(5, 2), nullable=False, default=0) # Percentage
tax_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
total_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
currency_code = db.Column(db.String(3), nullable=False, default='EUR')
template_id = db.Column(db.Integer, db.ForeignKey('invoice_templates.id'), nullable=True, index=True)
# Notes and terms
notes = db.Column(db.Text, nullable=True)
@@ -49,6 +51,10 @@ class Invoice(db.Model):
client = db.relationship('Client', backref='invoices')
creator = db.relationship('User', backref='created_invoices')
items = db.relationship('InvoiceItem', backref='invoice', lazy='dynamic', cascade='all, delete-orphan')
payments = db.relationship('Payment', backref='invoice', lazy='dynamic', cascade='all, delete-orphan')
credits = db.relationship('CreditNote', backref='invoice', lazy='dynamic', cascade='all, delete-orphan')
reminder_schedules = db.relationship('InvoiceReminderSchedule', backref='invoice', lazy='dynamic', cascade='all, delete-orphan')
template = db.relationship('InvoiceTemplate', backref='invoices', lazy='joined')
def __init__(self, invoice_number, project_id, client_name, due_date, created_by, client_id, **kwargs):
self.invoice_number = invoice_number
@@ -65,6 +71,8 @@ class Invoice(db.Model):
self.notes = kwargs.get('notes')
self.terms = kwargs.get('terms')
self.tax_rate = Decimal(str(kwargs.get('tax_rate', 0)))
self.currency_code = kwargs.get('currency_code') or self.currency_code
self.template_id = kwargs.get('template_id') if kwargs.get('template_id') else None
# Set payment tracking fields
self.payment_date = kwargs.get('payment_date')
@@ -102,7 +110,8 @@ class Invoice(db.Model):
@property
def outstanding_amount(self):
"""Calculate outstanding amount"""
return self.total_amount - (self.amount_paid or 0)
credits_total = sum((c.amount for c in self.credits), Decimal('0')) if self.credits else Decimal('0')
return self.total_amount - (self.amount_paid or 0) - credits_total
@property
def payment_percentage(self):
@@ -146,6 +155,11 @@ class Invoice(db.Model):
def calculate_totals(self):
"""Calculate invoice totals from items"""
# Optionally apply tax rules before totals
try:
self._apply_tax_rules_if_any()
except Exception:
pass
subtotal = sum(item.total_amount for item in self.items)
self.subtotal = subtotal
self.tax_amount = subtotal * (self.tax_rate / 100)
@@ -154,6 +168,36 @@ class Invoice(db.Model):
# Update status if overdue
if self.status == 'sent' and self.is_overdue:
self.status = 'overdue'
def _apply_tax_rules_if_any(self):
"""Apply matching tax rule to set `tax_rate` if applicable.
Chooses the most specific active rule by client->project->country/region.
"""
try:
from .tax_rule import TaxRule # local import to avoid circular
today = self.issue_date or datetime.utcnow().date()
query = TaxRule.query.filter(TaxRule.active == True)
# constrain by date range
query = query.filter(
(TaxRule.start_date.is_(None) | (TaxRule.start_date <= today)),
(TaxRule.end_date.is_(None) | (TaxRule.end_date >= today)),
)
candidates = []
# project-specific
if self.project_id:
candidates = query.filter(TaxRule.project_id == self.project_id).all()
# client-specific
if not candidates and self.client_id:
candidates = query.filter(TaxRule.client_id == self.client_id).all()
# no direct client/project, fallback to country/region — requires client meta; skip if unavailable
# choose first if any
if candidates:
# prefer highest rate if multiple
candidates.sort(key=lambda r: float(r.rate_percent), reverse=True)
self.tax_rate = Decimal(str(candidates[0].rate_percent))
except Exception:
# Best-effort only
pass
def to_dict(self):
"""Convert invoice to dictionary for API responses"""

View File

@@ -0,0 +1,23 @@
from datetime import datetime
from app import db
class InvoiceTemplate(db.Model):
"""Reusable invoice templates/themes with customizable HTML and CSS."""
__tablename__ = 'invoice_templates'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False, unique=True, index=True)
description = db.Column(db.String(255), nullable=True)
html = db.Column(db.Text, nullable=True)
css = db.Column(db.Text, nullable=True)
is_default = db.Column(db.Boolean, default=False, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
def __repr__(self):
return f"<InvoiceTemplate {self.name}>"

61
app/models/payments.py Normal file
View File

@@ -0,0 +1,61 @@
from datetime import datetime
from app import db
class Payment(db.Model):
"""Partial/full payments recorded against invoices."""
__tablename__ = 'payments'
id = db.Column(db.Integer, primary_key=True)
invoice_id = db.Column(db.Integer, db.ForeignKey('invoices.id'), nullable=False, index=True)
amount = db.Column(db.Numeric(10, 2), nullable=False)
currency = db.Column(db.String(3), nullable=True) # If multi-currency per payment
payment_date = db.Column(db.Date, nullable=False, default=datetime.utcnow)
method = db.Column(db.String(50), nullable=True)
reference = db.Column(db.String(100), nullable=True)
notes = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
def __repr__(self):
return f"<Payment {self.amount} for invoice {self.invoice_id}>"
class CreditNote(db.Model):
"""Credit notes issued to offset invoices."""
__tablename__ = 'credit_notes'
id = db.Column(db.Integer, primary_key=True)
invoice_id = db.Column(db.Integer, db.ForeignKey('invoices.id'), nullable=False, index=True)
credit_number = db.Column(db.String(50), unique=True, nullable=False, index=True)
amount = db.Column(db.Numeric(10, 2), nullable=False)
reason = db.Column(db.Text, nullable=True)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
def __repr__(self):
return f"<CreditNote {self.credit_number} for invoice {self.invoice_id}>"
class InvoiceReminderSchedule(db.Model):
"""Schedules to send invoice reminders before/after due dates."""
__tablename__ = 'invoice_reminder_schedules'
id = db.Column(db.Integer, primary_key=True)
invoice_id = db.Column(db.Integer, db.ForeignKey('invoices.id'), nullable=False, index=True)
days_offset = db.Column(db.Integer, nullable=False) # negative for before due, positive after
recipients = db.Column(db.Text, nullable=True) # comma-separated; default to client email if empty
template_name = db.Column(db.String(100), nullable=True)
active = db.Column(db.Boolean, default=True, nullable=False)
last_sent_at = db.Column(db.DateTime, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
def __repr__(self):
return f"<InvoiceReminderSchedule inv={self.invoice_id} offset={self.days_offset}>"

43
app/models/reporting.py Normal file
View File

@@ -0,0 +1,43 @@
from datetime import datetime
from app import db
class SavedReportView(db.Model):
"""Saved configurations for the custom report builder."""
__tablename__ = 'saved_report_views'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(120), nullable=False)
owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
scope = db.Column(db.String(20), default='private', nullable=False) # private, team, public
config_json = db.Column(db.Text, nullable=False) # JSON for filters, columns, groupings
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
def __repr__(self):
return f"<SavedReportView {self.name} ({self.scope})>"
class ReportEmailSchedule(db.Model):
"""Schedules to email saved reports on a cadence."""
__tablename__ = 'report_email_schedules'
id = db.Column(db.Integer, primary_key=True)
saved_view_id = db.Column(db.Integer, db.ForeignKey('saved_report_views.id'), nullable=False, index=True)
recipients = db.Column(db.Text, nullable=False) # comma-separated
cadence = db.Column(db.String(20), nullable=False) # daily, weekly, monthly, custom-cron
cron = db.Column(db.String(120), nullable=True)
timezone = db.Column(db.String(50), nullable=True)
next_run_at = db.Column(db.DateTime, nullable=True)
last_run_at = db.Column(db.DateTime, nullable=True)
active = db.Column(db.Boolean, default=True, nullable=False)
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
def __repr__(self):
return f"<ReportEmailSchedule view={self.saved_view_id} cadence={self.cadence}>"

30
app/models/tax_rule.py Normal file
View File

@@ -0,0 +1,30 @@
from datetime import datetime
from app import db
class TaxRule(db.Model):
"""Flexible tax rules per country/region/client with effective date ranges."""
__tablename__ = 'tax_rules'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False)
country = db.Column(db.String(2), nullable=True) # ISO-3166-1 alpha-2
region = db.Column(db.String(50), nullable=True)
client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=True, index=True)
project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=True, index=True)
tax_code = db.Column(db.String(50), nullable=True) # e.g., VAT, GST
rate_percent = db.Column(db.Numeric(7, 4), nullable=False, default=0)
compound = db.Column(db.Boolean, default=False, nullable=False)
inclusive = db.Column(db.Boolean, default=False, nullable=False) # If true, prices include tax
start_date = db.Column(db.Date, nullable=True)
end_date = db.Column(db.Date, nullable=True)
active = db.Column(db.Boolean, default=True, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
def __repr__(self):
return f"<TaxRule {self.name} {self.rate_percent}%>"