mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-06 20:40:38 -05:00
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:
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
# ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user