From db82068dfddb52cd2bc9e9551679b4d276d0c2ce Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Mon, 6 Oct 2025 13:51:24 +0200 Subject: [PATCH] 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 --- app/models/__init__.py | 9 +- app/models/currency.py | 44 ++++ app/models/invoice.py | 46 +++- app/models/invoice_template.py | 23 ++ app/models/payments.py | 61 +++++ app/models/reporting.py | 43 ++++ app/models/tax_rule.py | 30 +++ .../017_reporting_invoicing_extensions.py | 219 ++++++++++++++++++ 8 files changed, 473 insertions(+), 2 deletions(-) create mode 100644 app/models/currency.py create mode 100644 app/models/invoice_template.py create mode 100644 app/models/payments.py create mode 100644 app/models/reporting.py create mode 100644 app/models/tax_rule.py create mode 100644 migrations/versions/017_reporting_invoicing_extensions.py diff --git a/app/models/__init__.py b/app/models/__init__.py index 01b1685..fb2edec 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -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' ] diff --git a/app/models/currency.py b/app/models/currency.py new file mode 100644 index 0000000..577c09d --- /dev/null +++ b/app/models/currency.py @@ -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"" + + +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"" + + diff --git a/app/models/invoice.py b/app/models/invoice.py index d8b7782..8fdb6ac 100644 --- a/app/models/invoice.py +++ b/app/models/invoice.py @@ -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""" diff --git a/app/models/invoice_template.py b/app/models/invoice_template.py new file mode 100644 index 0000000..0db8b45 --- /dev/null +++ b/app/models/invoice_template.py @@ -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"" + + diff --git a/app/models/payments.py b/app/models/payments.py new file mode 100644 index 0000000..9f0cb37 --- /dev/null +++ b/app/models/payments.py @@ -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"" + + +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"" + + +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"" + + diff --git a/app/models/reporting.py b/app/models/reporting.py new file mode 100644 index 0000000..345484e --- /dev/null +++ b/app/models/reporting.py @@ -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"" + + +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"" + + diff --git a/app/models/tax_rule.py b/app/models/tax_rule.py new file mode 100644 index 0000000..c27fdc9 --- /dev/null +++ b/app/models/tax_rule.py @@ -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"" + + diff --git a/migrations/versions/017_reporting_invoicing_extensions.py b/migrations/versions/017_reporting_invoicing_extensions.py new file mode 100644 index 0000000..e217364 --- /dev/null +++ b/migrations/versions/017_reporting_invoicing_extensions.py @@ -0,0 +1,219 @@ +"""reporting and invoicing extensions + +Revision ID: 017 +Revises: 016 +Create Date: 2025-10-06 00:00:00 +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '017' +down_revision = '016' +branch_labels = None +depends_on = None + + +def _has_table(inspector, name: str) -> bool: + try: + return name in inspector.get_table_names() + except Exception: + return False + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + # currencies + if not _has_table(inspector, 'currencies'): + op.create_table( + 'currencies', + sa.Column('code', sa.String(length=3), primary_key=True), + sa.Column('name', sa.String(length=64), nullable=False), + sa.Column('symbol', sa.String(length=8), nullable=True), + sa.Column('decimal_places', sa.Integer(), nullable=False, server_default='2'), + sa.Column('is_active', sa.Boolean(), nullable=False, server_default=sa.text('true')), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + ) + + if not _has_table(inspector, 'exchange_rates'): + op.create_table( + 'exchange_rates', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('base_code', sa.String(length=3), sa.ForeignKey('currencies.code'), nullable=False, index=True), + sa.Column('quote_code', sa.String(length=3), sa.ForeignKey('currencies.code'), nullable=False, index=True), + sa.Column('rate', sa.Numeric(18, 8), nullable=False), + sa.Column('date', sa.Date(), nullable=False), + sa.Column('source', sa.String(length=50), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + sa.UniqueConstraint('base_code', 'quote_code', 'date', name='uq_exchange_rate_day'), + ) + + # tax rules + if not _has_table(inspector, 'tax_rules'): + op.create_table( + 'tax_rules', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('name', sa.String(length=100), nullable=False), + sa.Column('country', sa.String(length=2), nullable=True), + sa.Column('region', sa.String(length=50), nullable=True), + sa.Column('client_id', sa.Integer(), sa.ForeignKey('clients.id'), nullable=True), + sa.Column('project_id', sa.Integer(), sa.ForeignKey('projects.id'), nullable=True), + sa.Column('tax_code', sa.String(length=50), nullable=True), + sa.Column('rate_percent', sa.Numeric(7, 4), nullable=False, server_default='0'), + sa.Column('compound', sa.Boolean(), nullable=False, server_default=sa.text('false')), + sa.Column('inclusive', sa.Boolean(), nullable=False, server_default=sa.text('false')), + sa.Column('start_date', sa.Date(), nullable=True), + sa.Column('end_date', sa.Date(), nullable=True), + sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.text('true')), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + ) + + # invoice templates + if not _has_table(inspector, 'invoice_templates'): + op.create_table( + 'invoice_templates', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('name', sa.String(length=100), nullable=False, unique=True), + sa.Column('description', sa.String(length=255), nullable=True), + sa.Column('html', sa.Text(), nullable=True), + sa.Column('css', sa.Text(), nullable=True), + sa.Column('is_default', sa.Boolean(), nullable=False, server_default=sa.text('false')), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + ) + + # payments, credit notes, reminders + if not _has_table(inspector, 'payments'): + op.create_table( + 'payments', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('invoice_id', sa.Integer(), sa.ForeignKey('invoices.id', ondelete='CASCADE'), nullable=False), + sa.Column('amount', sa.Numeric(10, 2), nullable=False), + sa.Column('currency', sa.String(length=3), nullable=True), + sa.Column('payment_date', sa.Date(), nullable=False, server_default=sa.text('CURRENT_DATE')), + sa.Column('method', sa.String(length=50), nullable=True), + sa.Column('reference', sa.String(length=100), nullable=True), + sa.Column('notes', sa.Text(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + ) + + if not _has_table(inspector, 'credit_notes'): + op.create_table( + 'credit_notes', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('invoice_id', sa.Integer(), sa.ForeignKey('invoices.id', ondelete='CASCADE'), nullable=False), + sa.Column('credit_number', sa.String(length=50), nullable=False, unique=True), + sa.Column('amount', sa.Numeric(10, 2), nullable=False), + sa.Column('reason', sa.Text(), nullable=True), + sa.Column('created_by', sa.Integer(), sa.ForeignKey('users.id'), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + ) + + if not _has_table(inspector, 'invoice_reminder_schedules'): + op.create_table( + 'invoice_reminder_schedules', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('invoice_id', sa.Integer(), sa.ForeignKey('invoices.id', ondelete='CASCADE'), nullable=False), + sa.Column('days_offset', sa.Integer(), nullable=False), + sa.Column('recipients', sa.Text(), nullable=True), + sa.Column('template_name', sa.String(length=100), nullable=True), + sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.text('true')), + sa.Column('last_sent_at', sa.DateTime(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + ) + + # reporting saved views and schedules + if not _has_table(inspector, 'saved_report_views'): + op.create_table( + 'saved_report_views', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('name', sa.String(length=120), nullable=False), + sa.Column('owner_id', sa.Integer(), sa.ForeignKey('users.id'), nullable=False), + sa.Column('scope', sa.String(length=20), nullable=False, server_default='private'), + sa.Column('config_json', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + ) + + if not _has_table(inspector, 'report_email_schedules'): + op.create_table( + 'report_email_schedules', + sa.Column('id', sa.Integer(), primary_key=True), + sa.Column('saved_view_id', sa.Integer(), sa.ForeignKey('saved_report_views.id', ondelete='CASCADE'), nullable=False), + sa.Column('recipients', sa.Text(), nullable=False), + sa.Column('cadence', sa.String(length=20), nullable=False), + sa.Column('cron', sa.String(length=120), nullable=True), + sa.Column('timezone', sa.String(length=50), nullable=True), + sa.Column('next_run_at', sa.DateTime(), nullable=True), + sa.Column('last_run_at', sa.DateTime(), nullable=True), + sa.Column('active', sa.Boolean(), nullable=False, server_default=sa.text('true')), + sa.Column('created_by', sa.Integer(), sa.ForeignKey('users.id'), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')), + ) + + # alter invoices: currency_code, template_id + if 'invoices' in inspector.get_table_names(): + columns = {c['name'] for c in inspector.get_columns('invoices')} + if 'currency_code' not in columns: + op.add_column('invoices', sa.Column('currency_code', sa.String(length=3), nullable=False, server_default='EUR')) + op.alter_column('invoices', 'currency_code', server_default=None) + if 'template_id' not in columns: + op.add_column('invoices', sa.Column('template_id', sa.Integer(), nullable=True)) + try: + op.create_index('ix_invoices_template_id', 'invoices', ['template_id']) + except Exception: + pass + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + # remove added invoice columns + if 'invoices' in inspector.get_table_names(): + columns = {c['name'] for c in inspector.get_columns('invoices')} + if 'template_id' in columns: + try: + op.drop_index('ix_invoices_template_id', table_name='invoices') + except Exception: + pass + try: + op.drop_column('invoices', 'template_id') + except Exception: + pass + if 'currency_code' in columns: + try: + op.drop_column('invoices', 'currency_code') + except Exception: + pass + + # drop tables (reverse order due FK dependencies) + for table in [ + 'report_email_schedules', + 'saved_report_views', + 'invoice_reminder_schedules', + 'credit_notes', + 'payments', + 'invoice_templates', + 'tax_rules', + 'exchange_rates', + 'currencies', + ]: + if _has_table(inspector, table): + try: + op.drop_table(table) + except Exception: + pass + +