Add prepaid-form parsing, tame console noise, and fix invoice UI

parse prepaid hour/reset fields on client edit/create; guard invalid values with new route tests
suppress benign ResizeObserver warnings globally and load handler on standalone pages
raise invoice actions dropdown as a floating menu so it isn’t clipped or scroll-locking
This commit is contained in:
Dries Peeters
2025-11-12 08:15:04 +01:00
parent 14ae197266
commit d3f6a792dd
24 changed files with 1148 additions and 55 deletions
+62
View File
@@ -0,0 +1,62 @@
import pytest
from datetime import datetime, date, timedelta
from decimal import Decimal
from app import db
from app.models import Client, Project, Invoice, TimeEntry
@pytest.mark.smoke
def test_prepaid_hours_summary_display(app, client, user):
"""Smoke test to ensure prepaid hours summary renders on generate-from-time page."""
with client.session_transaction() as sess:
sess['_user_id'] = str(user.id)
sess['_fresh'] = True
prepaid_client = Client(
name='Smoke Prepaid',
email='smoke@example.com',
prepaid_hours_monthly=Decimal('50'),
prepaid_reset_day=1
)
db.session.add(prepaid_client)
db.session.commit()
project = Project(
name='Smoke Project',
client_id=prepaid_client.id,
billable=True,
hourly_rate=Decimal('85.00')
)
db.session.add(project)
db.session.commit()
invoice = Invoice(
invoice_number='INV-SMOKE-001',
project_id=project.id,
client_name=prepaid_client.name,
client_id=prepaid_client.id,
due_date=date.today() + timedelta(days=14),
created_by=user.id
)
db.session.add(invoice)
db.session.commit()
start = datetime.utcnow() - timedelta(hours=5)
end = datetime.utcnow()
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start,
end_time=end,
billable=True
)
db.session.add(entry)
db.session.commit()
response = client.get(f'/invoices/{invoice.id}/generate-from-time')
assert response.status_code == 200
html = response.get_data(as_text=True)
assert 'Prepaid Hours Overview' in html
assert 'Monthly Prepaid Hours' not in html # ensure we are on the summary, not the form
+59
View File
@@ -0,0 +1,59 @@
import pytest
from datetime import datetime, date
from decimal import Decimal
from app import db
from app.models import Client, ClientPrepaidConsumption, User, Project, TimeEntry
@pytest.mark.models
def test_client_prepaid_properties_and_consumption(app):
client = Client(
name='Model Client',
prepaid_hours_monthly=Decimal('40.0'),
prepaid_reset_day=5
)
db.session.add(client)
db.session.commit()
assert client.prepaid_plan_enabled is True
assert client.prepaid_hours_decimal == Decimal('40.00')
reference = datetime(2025, 3, 7, 12, 0, 0)
period_start = client.prepaid_month_start(reference)
assert period_start == date(2025, 3, 5)
user = User(username='modeluser', email='modeluser@example.com')
db.session.add(user)
db.session.commit()
project = Project(name='Model Project', client_id=client.id, billable=True)
db.session.add(project)
db.session.commit()
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=datetime(2025, 3, 5, 9, 0, 0),
end_time=datetime(2025, 3, 5, 21, 0, 0),
billable=True
)
db.session.add(entry)
db.session.commit()
# Create a consumption record for 12 hours
consumption = ClientPrepaidConsumption(
client_id=client.id,
time_entry_id=entry.id,
allocation_month=period_start,
seconds_consumed=12 * 3600
)
db.session.add(consumption)
db.session.commit()
consumed = client.get_prepaid_consumed_hours(period_start)
remaining = client.get_prepaid_remaining_hours(period_start)
assert consumed.quantize(Decimal('0.01')) == Decimal('12.00')
assert remaining.quantize(Decimal('0.01')) == Decimal('28.00')
+83 -1
View File
@@ -2,7 +2,7 @@ import pytest
from datetime import datetime, date, timedelta
from decimal import Decimal
from app import db
from app.models import User, Project, Invoice, InvoiceItem, Settings, Client, ExtraGood
from app.models import User, Project, Invoice, InvoiceItem, Settings, Client, ExtraGood, ClientPrepaidConsumption
@pytest.fixture
def sample_user(app):
@@ -380,6 +380,88 @@ def test_generate_from_time_page_renders_lists(app, client, user, project):
assert 'Total available hours' in html
assert 'Total available costs' in html
@pytest.mark.routes
def test_generate_from_time_applies_prepaid_hours(app, client, user):
"""Ensure prepaid hours are consumed before billing when generating invoice items."""
from app import db
from app.models import TimeEntry
# Authenticate
with client.session_transaction() as sess:
sess['_user_id'] = str(user.id)
sess['_fresh'] = True
prepaid_client = Client(
name='Prepaid Client',
email='prepaid@example.com',
prepaid_hours_monthly=Decimal('50.0'),
prepaid_reset_day=1
)
db.session.add(prepaid_client)
db.session.commit()
project = Project(
name='Prepaid Project',
client_id=prepaid_client.id,
billable=True,
hourly_rate=Decimal('120.00')
)
db.session.add(project)
db.session.commit()
invoice = Invoice(
invoice_number='INV-PREPAID-001',
project_id=project.id,
client_name=prepaid_client.name,
client_id=prepaid_client.id,
due_date=date.today() + timedelta(days=14),
created_by=user.id
)
db.session.add(invoice)
db.session.commit()
base_start = datetime(2025, 1, 5, 9, 0, 0)
hours_blocks = [Decimal('20'), Decimal('20'), Decimal('20')]
entries = []
for idx, hours in enumerate(hours_blocks):
start = base_start + timedelta(days=idx * 3)
end = start + timedelta(hours=float(hours))
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start,
end_time=end,
notes=f'Prepaid block {idx + 1}',
billable=True
)
db.session.add(entry)
db.session.commit()
db.session.refresh(entry)
entries.append(entry)
data = {
'time_entries[]': [str(entry.id) for entry in entries]
}
resp = client.post(f'/invoices/{invoice.id}/generate-from-time', data=data)
assert resp.status_code == 302
invoice = Invoice.query.get(invoice.id)
items = list(invoice.items)
assert len(items) == 1
assert items[0].quantity == Decimal('10.00')
# All prepaid consumptions registered (50 hours = 180000 seconds)
consumptions = ClientPrepaidConsumption.query.filter_by(client_id=prepaid_client.id).all()
assert len(consumptions) == 3
assert sum(c.seconds_consumed for c in consumptions) == 50 * 3600
db.session.refresh(entries[0])
db.session.refresh(entries[1])
db.session.refresh(entries[2])
assert entries[0].billable is False
assert entries[1].billable is False
assert entries[2].billable is True
# Payment Status Tracking Tests
def test_invoice_payment_status_initialization(app, sample_user, sample_project):
+80
View File
@@ -0,0 +1,80 @@
import pytest
from datetime import datetime, timedelta, date
from decimal import Decimal
from app import db
from app.models import Client, Project, TimeEntry, Invoice, ClientPrepaidConsumption
from app.utils.prepaid_hours import PrepaidHoursAllocator
@pytest.mark.unit
def test_prepaid_allocator_partial_allocation(app, user):
"""Prepaid allocator should consume available hours and bill the remainder."""
client = Client(
name='Allocator Client',
email='allocator@example.com',
prepaid_hours_monthly=Decimal('5.0'),
prepaid_reset_day=1
)
db.session.add(client)
db.session.commit()
project = Project(
name='Allocator Project',
client_id=client.id,
billable=True,
hourly_rate=Decimal('90.00')
)
db.session.add(project)
db.session.commit()
invoice = Invoice(
invoice_number='INV-ALLOC-001',
project_id=project.id,
client_name=client.name,
client_id=client.id,
due_date=date.today() + timedelta(days=30),
created_by=user.id
)
db.session.add(invoice)
db.session.commit()
base_start = datetime(2025, 2, 10, 9, 0, 0)
hours_blocks = [Decimal('3.0'), Decimal('4.0')]
entries = []
for idx, hours in enumerate(hours_blocks):
start = base_start + timedelta(days=idx)
end = start + timedelta(hours=float(hours))
entry = TimeEntry(
user_id=user.id,
project_id=project.id,
start_time=start,
end_time=end,
billable=True,
notes=f'Allocation block {idx + 1}'
)
db.session.add(entry)
db.session.commit()
db.session.refresh(entry)
entries.append(entry)
allocator = PrepaidHoursAllocator(client=client, invoice=invoice)
processed = allocator.process(entries)
db.session.flush()
assert len(processed) == 2
assert processed[0].prepaid_hours == Decimal('3.00')
assert processed[0].billable_hours == Decimal('0.00')
assert processed[1].prepaid_hours == Decimal('2.00')
assert processed[1].billable_hours == Decimal('2.00')
assert allocator.total_prepaid_hours_assigned == Decimal('5.00')
consumptions = ClientPrepaidConsumption.query.filter_by(client_id=client.id).order_by(ClientPrepaidConsumption.time_entry_id).all()
assert len(consumptions) == 2
assert sum(c.seconds_consumed for c in consumptions) == 5 * 3600
db.session.refresh(entries[0])
db.session.refresh(entries[1])
assert entries[0].billable is False
assert entries[1].billable is True
+74
View File
@@ -254,6 +254,80 @@ def test_client_detail_page(authenticated_client, test_client, app):
assert response.status_code == 200
@pytest.mark.integration
@pytest.mark.routes
def test_edit_client_updates_prepaid_fields(admin_authenticated_client, test_client, app):
"""Ensure editing a client updates prepaid hours fields without errors."""
from app import db
from app.models import Client
with app.app_context():
client_id = test_client.id
response = admin_authenticated_client.post(
f'/clients/{client_id}/edit',
data={
'name': test_client.name,
'description': test_client.description or '',
'contact_person': test_client.contact_person or '',
'email': test_client.email or '',
'phone': test_client.phone or '',
'address': test_client.address or '',
'default_hourly_rate': '',
'prepaid_hours_monthly': '12.5',
'prepaid_reset_day': '10',
},
follow_redirects=False,
)
assert response.status_code == 302
db.session.expire_all()
updated = Client.query.get(client_id)
assert updated is not None
assert updated.prepaid_hours_monthly == Decimal('12.5')
assert updated.prepaid_reset_day == 10
@pytest.mark.integration
@pytest.mark.routes
def test_edit_client_rejects_negative_prepaid_hours(admin_authenticated_client, test_client, app):
"""Regression test: negative prepaid hours should trigger validation error."""
from app import db
from app.models import Client
with app.app_context():
client_id = test_client.id
db.session.expire_all()
baseline = Client.query.get(client_id)
baseline_hours = baseline.prepaid_hours_monthly
baseline_reset_day = baseline.prepaid_reset_day
response = admin_authenticated_client.post(
f'/clients/{client_id}/edit',
data={
'name': test_client.name,
'description': test_client.description or '',
'contact_person': test_client.contact_person or '',
'email': test_client.email or '',
'phone': test_client.phone or '',
'address': test_client.address or '',
'default_hourly_rate': '',
'prepaid_hours_monthly': '-1',
'prepaid_reset_day': '3',
},
follow_redirects=False,
)
# View should re-render with validation error (200 OK)
assert response.status_code == 200
db.session.expire_all()
updated = Client.query.get(client_id)
assert updated.prepaid_hours_monthly == baseline_hours
assert updated.prepaid_reset_day == baseline_reset_day
# ============================================================================
# Reports Routes
# ============================================================================