mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-11 23:10:35 -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:
+69
-3
@@ -15,11 +15,14 @@ This document summarizes all critical bugs discovered and fixed during the deplo
|
||||
| 1 | `sqlalchemy.exc.InvalidRequestError: Attribute name 'metadata' is reserved` | Reserved SQLAlchemy keyword used as column name | ✅ Fixed | 3 |
|
||||
| 2 | `ImportError: cannot import name 'db' from 'app.models'` | Wrong import source for db instance | ✅ Fixed | 2 |
|
||||
| 3 | `ModuleNotFoundError: No module named 'app.utils.db_helpers'` | Wrong module name in imports | ✅ Fixed | 2 |
|
||||
| 4 | `NameError: name 'prepaid_hours_input' is not defined` when editing client | Missing form parsing for prepaid fields in edit route | ✅ Fixed | 3 |
|
||||
| 5 | `ResizeObserver loop completed with undelivered notifications` spam in console | Benign browser warning surfaced as toast by enhanced error handler | ✅ Fixed | 1 |
|
||||
| 6 | Invoice actions dropdown hidden behind table rows | Dropdown stacked under sibling elements due to z-index/overflow | ✅ Fixed | 1 |
|
||||
|
||||
**Total Bugs**: 3
|
||||
**Total Bugs**: 6
|
||||
**All Fixed**: ✅
|
||||
**Files Modified**: 5
|
||||
**Total Resolution Time**: ~10 minutes
|
||||
**Files Modified**: 10
|
||||
**Total Resolution Time**: ~20 minutes
|
||||
|
||||
---
|
||||
|
||||
@@ -113,6 +116,69 @@ from app.utils.db import safe_commit
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Bug #4: Missing Form Parsing for Prepaid Fields
|
||||
|
||||
### Error
|
||||
```
|
||||
NameError: name 'prepaid_hours_input' is not defined
|
||||
```
|
||||
|
||||
### Root Cause
|
||||
The client edit route validated `prepaid_hours_input` and `prepaid_reset_day_input` but never read those form fields, causing a NameError when users tried to update prepaid hours.
|
||||
|
||||
### Fix
|
||||
- Parse `prepaid_hours_monthly` and `prepaid_reset_day` from the form before validation
|
||||
- Added regression tests to cover successful updates and negative-hour validation
|
||||
- Documented the issue and fix in this summary
|
||||
|
||||
### Files Modified
|
||||
1. `app/routes/clients.py` - Read prepaid form fields before validation
|
||||
2. `tests/test_routes.py` - Added route regression tests for prepaid editing
|
||||
3. `ALL_BUGFIXES_SUMMARY.md` - Documented the fix
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Bug #5: Benign ResizeObserver Warnings Flooding Error Handler
|
||||
|
||||
### Error
|
||||
```
|
||||
ResizeObserver loop completed with undelivered notifications.
|
||||
```
|
||||
|
||||
### Root Cause
|
||||
Certain UI components trigger harmless `ResizeObserver` warnings in Chromium-based browsers. These were caught by the global error handler, surfaced to users as critical toasts, and logged as console errors.
|
||||
|
||||
### Fix
|
||||
- Added noise filtering in `error-handling-enhanced.js` to ignore known benign ResizeObserver warnings while still logging other errors.
|
||||
- Downgraded these messages to `console.debug` so developers can inspect them without user-facing noise.
|
||||
- Updated bug summary documentation (this file).
|
||||
|
||||
### Files Modified
|
||||
1. `app/static/error-handling-enhanced.js`
|
||||
2. `ALL_BUGFIXES_SUMMARY.md`
|
||||
|
||||
---
|
||||
|
||||
## 🔧 Bug #6: Invoice Actions Dropdown Hidden Behind Content
|
||||
|
||||
### Error
|
||||
Invoice row actions menu appeared underneath neighboring table content, hiding menu items from the user.
|
||||
|
||||
### Root Cause
|
||||
The dropdown relied on a modest `z-index` within a stacking context created by the grid/table layout. Parent cells also defaulted to clipping overflow, so the menu rendered below adjacent elements.
|
||||
|
||||
- ### Fix
|
||||
- Marked the actions cell as `relative overflow-visible` so the dropdown can extend beyond the table cell.
|
||||
- Elevated the dropdown with a dedicated class and runtime positioning logic that renders it as a floating menu (fixed to the viewport) to avoid impacting table height.
|
||||
- Added scroll/resize listeners to collapse the menu when the layout changes, preventing stray overlays.
|
||||
- Documented the bug in this summary.
|
||||
|
||||
### Files Modified
|
||||
1. `app/templates/invoices/list.html`
|
||||
2. `ALL_BUGFIXES_SUMMARY.md`
|
||||
|
||||
---
|
||||
|
||||
## ✅ Verification
|
||||
|
||||
All fixes have been verified:
|
||||
|
||||
@@ -10,6 +10,7 @@ from .tax_rule import TaxRule
|
||||
from .payments import Payment, CreditNote, InvoiceReminderSchedule
|
||||
from .reporting import SavedReportView, ReportEmailSchedule
|
||||
from .client import Client
|
||||
from .client_prepaid_consumption import ClientPrepaidConsumption
|
||||
from .task_activity import TaskActivity
|
||||
from .expense_category import ExpenseCategory
|
||||
from .mileage import Mileage
|
||||
@@ -75,4 +76,5 @@ __all__ = [
|
||||
"DataImport",
|
||||
"DataExport",
|
||||
"InvoicePDFTemplate",
|
||||
"ClientPrepaidConsumption",
|
||||
]
|
||||
|
||||
+86
-1
@@ -1,6 +1,7 @@
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from app import db
|
||||
from .client_prepaid_consumption import ClientPrepaidConsumption
|
||||
|
||||
class Client(db.Model):
|
||||
"""Client model for managing client information and rates"""
|
||||
@@ -16,13 +17,15 @@ class Client(db.Model):
|
||||
address = db.Column(db.Text, nullable=True)
|
||||
default_hourly_rate = db.Column(db.Numeric(9, 2), nullable=True)
|
||||
status = db.Column(db.String(20), default='active', nullable=False) # 'active' or 'inactive'
|
||||
prepaid_hours_monthly = db.Column(db.Numeric(7, 2), nullable=True)
|
||||
prepaid_reset_day = db.Column(db.Integer, nullable=False, default=1)
|
||||
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)
|
||||
|
||||
# Relationships
|
||||
projects = db.relationship('Project', backref='client_obj', lazy='dynamic', cascade='all, delete-orphan')
|
||||
|
||||
def __init__(self, name, description=None, contact_person=None, email=None, phone=None, address=None, default_hourly_rate=None, company=None):
|
||||
def __init__(self, name, description=None, contact_person=None, email=None, phone=None, address=None, default_hourly_rate=None, company=None, prepaid_hours_monthly=None, prepaid_reset_day=1):
|
||||
"""Create a Client.
|
||||
|
||||
Note: company parameter is accepted for test compatibility but not used,
|
||||
@@ -35,6 +38,12 @@ class Client(db.Model):
|
||||
self.phone = phone.strip() if phone else None
|
||||
self.address = address.strip() if address else None
|
||||
self.default_hourly_rate = Decimal(str(default_hourly_rate)) if default_hourly_rate else None
|
||||
self.prepaid_hours_monthly = Decimal(str(prepaid_hours_monthly)) if prepaid_hours_monthly not in (None, '') else None
|
||||
try:
|
||||
reset_day = int(prepaid_reset_day) if prepaid_reset_day is not None else 1
|
||||
self.prepaid_reset_day = max(1, min(28, reset_day))
|
||||
except (TypeError, ValueError):
|
||||
self.prepaid_reset_day = 1
|
||||
|
||||
def __repr__(self):
|
||||
return f'<Client {self.name}>'
|
||||
@@ -78,6 +87,80 @@ class Client(db.Model):
|
||||
if project.billable and project.hourly_rate:
|
||||
total_cost += project.estimated_cost
|
||||
return total_cost
|
||||
|
||||
@property
|
||||
def prepaid_plan_enabled(self):
|
||||
"""Return True if client has prepaid hours configured."""
|
||||
try:
|
||||
hours = Decimal(str(self.prepaid_hours_monthly)) if self.prepaid_hours_monthly is not None else Decimal('0')
|
||||
except Exception:
|
||||
hours = Decimal('0')
|
||||
return hours > 0
|
||||
|
||||
@property
|
||||
def prepaid_hours_decimal(self):
|
||||
"""Return prepaid hours as Decimal with two decimal precision."""
|
||||
if self.prepaid_hours_monthly is None:
|
||||
return Decimal('0')
|
||||
try:
|
||||
return Decimal(str(self.prepaid_hours_monthly)).quantize(Decimal('0.01'))
|
||||
except Exception:
|
||||
return Decimal('0')
|
||||
|
||||
def prepaid_month_start(self, reference_datetime):
|
||||
"""
|
||||
Determine the configured prepaid period start date for a given datetime.
|
||||
|
||||
Args:
|
||||
reference_datetime (datetime): Datetime to evaluate.
|
||||
Returns:
|
||||
date: The start date of the prepaid cycle that contains the reference datetime.
|
||||
"""
|
||||
from datetime import timedelta
|
||||
|
||||
if not reference_datetime:
|
||||
return None
|
||||
|
||||
reset_day = self.prepaid_reset_day or 1
|
||||
reset_day = max(1, min(28, int(reset_day)))
|
||||
|
||||
dt = reference_datetime
|
||||
if isinstance(dt, datetime) and hasattr(dt, 'date'):
|
||||
dt_date = dt.date()
|
||||
else:
|
||||
dt_date = dt
|
||||
|
||||
if dt_date.day >= reset_day:
|
||||
return dt_date.replace(day=reset_day)
|
||||
|
||||
# Move to previous month
|
||||
first_of_month = dt_date.replace(day=1)
|
||||
previous_day = first_of_month - timedelta(days=1)
|
||||
target_day = min(reset_day, previous_day.day)
|
||||
return previous_day.replace(day=target_day)
|
||||
|
||||
def get_prepaid_consumed_hours(self, month_start):
|
||||
"""Return Decimal hours consumed for the given prepaid cycle."""
|
||||
if not month_start:
|
||||
return Decimal('0')
|
||||
|
||||
try:
|
||||
seconds = self.prepaid_consumptions.filter(
|
||||
ClientPrepaidConsumption.allocation_month == month_start
|
||||
).with_entities(
|
||||
db.func.coalesce(db.func.sum(ClientPrepaidConsumption.seconds_consumed), 0)
|
||||
).scalar() or 0
|
||||
except Exception:
|
||||
seconds = 0
|
||||
return Decimal(seconds) / Decimal('3600')
|
||||
|
||||
def get_prepaid_remaining_hours(self, month_start):
|
||||
"""Return how many prepaid hours remain for the cycle starting at month_start."""
|
||||
if not self.prepaid_plan_enabled or not month_start:
|
||||
return Decimal('0')
|
||||
consumed = self.get_prepaid_consumed_hours(month_start)
|
||||
remaining = self.prepaid_hours_decimal - consumed
|
||||
return remaining if remaining > 0 else Decimal('0')
|
||||
|
||||
def archive(self):
|
||||
"""Archive the client"""
|
||||
@@ -104,6 +187,8 @@ class Client(db.Model):
|
||||
'is_active': self.is_active,
|
||||
'total_projects': self.total_projects,
|
||||
'active_projects': self.active_projects,
|
||||
'prepaid_hours_monthly': float(self.prepaid_hours_monthly) if self.prepaid_hours_monthly is not None else None,
|
||||
'prepaid_reset_day': self.prepaid_reset_day,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from app import db
|
||||
|
||||
|
||||
class ClientPrepaidConsumption(db.Model):
|
||||
"""Ledger entries tracking which time entries consumed prepaid hours."""
|
||||
|
||||
__tablename__ = 'client_prepaid_consumptions'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=False, index=True)
|
||||
time_entry_id = db.Column(db.Integer, db.ForeignKey('time_entries.id'), nullable=False, unique=True, index=True)
|
||||
invoice_id = db.Column(db.Integer, db.ForeignKey('invoices.id'), nullable=True, index=True)
|
||||
allocation_month = db.Column(db.Date, nullable=False, index=True)
|
||||
seconds_consumed = db.Column(db.Integer, 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)
|
||||
|
||||
# Relationships
|
||||
client = db.relationship('Client', backref=db.backref('prepaid_consumptions', lazy='dynamic', cascade='all, delete-orphan'))
|
||||
time_entry = db.relationship('TimeEntry', backref=db.backref('prepaid_consumption', uselist=False))
|
||||
invoice = db.relationship('Invoice', backref=db.backref('prepaid_consumptions', lazy='dynamic'))
|
||||
|
||||
def __repr__(self):
|
||||
month = self.allocation_month.isoformat() if self.allocation_month else '?'
|
||||
return f'<ClientPrepaidConsumption client={self.client_id} entry={self.time_entry_id} month={month}>'
|
||||
|
||||
@property
|
||||
def hours_consumed(self) -> Decimal:
|
||||
"""Return consumed prepaid hours as Decimal."""
|
||||
if not self.seconds_consumed:
|
||||
return Decimal('0')
|
||||
return (Decimal(self.seconds_consumed) / Decimal('3600')).quantize(Decimal('0.01'))
|
||||
|
||||
+70
-6
@@ -5,7 +5,7 @@ import app as app_module
|
||||
from app import db
|
||||
from app.models import Client, Project
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from app.utils.db import safe_commit
|
||||
from app.utils.permissions import admin_or_permission_required
|
||||
from app.utils.timezone import convert_app_datetime_to_user
|
||||
@@ -85,6 +85,8 @@ def create_client():
|
||||
phone = request.form.get('phone', '').strip()
|
||||
address = request.form.get('address', '').strip()
|
||||
default_hourly_rate = request.form.get('default_hourly_rate', '').strip()
|
||||
prepaid_hours_input = request.form.get('prepaid_hours_monthly', '').strip()
|
||||
prepaid_reset_day_input = request.form.get('prepaid_reset_day', '').strip()
|
||||
try:
|
||||
current_app.logger.info(
|
||||
"POST /clients/create user=%s name=%s email=%s",
|
||||
@@ -120,7 +122,7 @@ def create_client():
|
||||
# Validate hourly rate
|
||||
try:
|
||||
default_hourly_rate = Decimal(default_hourly_rate) if default_hourly_rate else None
|
||||
except ValueError:
|
||||
except (InvalidOperation, ValueError):
|
||||
if wants_json:
|
||||
return jsonify({'error': 'validation_error', 'messages': ['Invalid hourly rate format']}), 400
|
||||
flash('Invalid hourly rate format', 'error')
|
||||
@@ -129,6 +131,29 @@ def create_client():
|
||||
except Exception:
|
||||
pass
|
||||
return render_template('clients/create.html')
|
||||
|
||||
try:
|
||||
prepaid_hours_monthly = Decimal(prepaid_hours_input) if prepaid_hours_input else None
|
||||
if prepaid_hours_monthly is not None and prepaid_hours_monthly < 0:
|
||||
raise InvalidOperation
|
||||
except (InvalidOperation, ValueError):
|
||||
message = _('Prepaid hours must be a positive number.')
|
||||
if wants_json:
|
||||
return jsonify({'error': 'validation_error', 'messages': [message]}), 400
|
||||
flash(message, 'error')
|
||||
return render_template('clients/create.html')
|
||||
|
||||
try:
|
||||
prepaid_reset_day = int(prepaid_reset_day_input) if prepaid_reset_day_input else 1
|
||||
except ValueError:
|
||||
prepaid_reset_day = 1
|
||||
|
||||
if prepaid_reset_day < 1 or prepaid_reset_day > 28:
|
||||
message = _('Prepaid reset day must be between 1 and 28.')
|
||||
if wants_json:
|
||||
return jsonify({'error': 'validation_error', 'messages': [message]}), 400
|
||||
flash(message, 'error')
|
||||
return render_template('clients/create.html')
|
||||
|
||||
# Create client
|
||||
client = Client(
|
||||
@@ -138,7 +163,9 @@ def create_client():
|
||||
email=email,
|
||||
phone=phone,
|
||||
address=address,
|
||||
default_hourly_rate=default_hourly_rate
|
||||
default_hourly_rate=default_hourly_rate,
|
||||
prepaid_hours_monthly=prepaid_hours_monthly,
|
||||
prepaid_reset_day=prepaid_reset_day
|
||||
)
|
||||
|
||||
db.session.add(client)
|
||||
@@ -156,7 +183,9 @@ def create_client():
|
||||
return jsonify({
|
||||
'id': client.id,
|
||||
'name': client.name,
|
||||
'default_hourly_rate': float(client.default_hourly_rate) if client.default_hourly_rate is not None else None
|
||||
'default_hourly_rate': float(client.default_hourly_rate) if client.default_hourly_rate is not None else None,
|
||||
'prepaid_hours_monthly': float(client.prepaid_hours_monthly) if client.prepaid_hours_monthly is not None else None,
|
||||
'prepaid_reset_day': client.prepaid_reset_day
|
||||
}), 201
|
||||
|
||||
flash(f'Client "{name}" created successfully', 'success')
|
||||
@@ -172,8 +201,22 @@ def view_client(client_id):
|
||||
|
||||
# Get projects for this client
|
||||
projects = Project.query.filter_by(client_id=client.id).order_by(Project.name).all()
|
||||
|
||||
prepaid_overview = None
|
||||
if client.prepaid_plan_enabled:
|
||||
today = datetime.utcnow()
|
||||
month_start = client.prepaid_month_start(today)
|
||||
consumed_hours = client.get_prepaid_consumed_hours(month_start).quantize(Decimal('0.01'))
|
||||
remaining_hours = client.get_prepaid_remaining_hours(month_start).quantize(Decimal('0.01'))
|
||||
prepaid_overview = {
|
||||
'month_start': month_start,
|
||||
'month_label': month_start.strftime('%Y-%m-%d') if month_start else '',
|
||||
'plan_hours': float(client.prepaid_hours_decimal),
|
||||
'consumed_hours': float(consumed_hours),
|
||||
'remaining_hours': float(remaining_hours),
|
||||
}
|
||||
|
||||
return render_template('clients/view.html', client=client, projects=projects)
|
||||
return render_template('clients/view.html', client=client, projects=projects, prepaid_overview=prepaid_overview)
|
||||
|
||||
@clients_bp.route('/clients/<int:client_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@@ -194,6 +237,8 @@ def edit_client(client_id):
|
||||
phone = request.form.get('phone', '').strip()
|
||||
address = request.form.get('address', '').strip()
|
||||
default_hourly_rate = request.form.get('default_hourly_rate', '').strip()
|
||||
prepaid_hours_input = request.form.get('prepaid_hours_monthly', '').strip()
|
||||
prepaid_reset_day_input = request.form.get('prepaid_reset_day', '').strip()
|
||||
|
||||
# Validate required fields
|
||||
if not name:
|
||||
@@ -209,9 +254,26 @@ def edit_client(client_id):
|
||||
# Validate hourly rate
|
||||
try:
|
||||
default_hourly_rate = Decimal(default_hourly_rate) if default_hourly_rate else None
|
||||
except ValueError:
|
||||
except (InvalidOperation, ValueError):
|
||||
flash('Invalid hourly rate format', 'error')
|
||||
return render_template('clients/edit.html', client=client)
|
||||
|
||||
try:
|
||||
prepaid_hours_monthly = Decimal(prepaid_hours_input) if prepaid_hours_input else None
|
||||
if prepaid_hours_monthly is not None and prepaid_hours_monthly < 0:
|
||||
raise InvalidOperation
|
||||
except (InvalidOperation, ValueError):
|
||||
flash(_('Prepaid hours must be a positive number.'), 'error')
|
||||
return render_template('clients/edit.html', client=client)
|
||||
|
||||
try:
|
||||
prepaid_reset_day = int(prepaid_reset_day_input) if prepaid_reset_day_input else client.prepaid_reset_day or 1
|
||||
except ValueError:
|
||||
prepaid_reset_day = client.prepaid_reset_day or 1
|
||||
|
||||
if prepaid_reset_day < 1 or prepaid_reset_day > 28:
|
||||
flash(_('Prepaid reset day must be between 1 and 28.'), 'error')
|
||||
return render_template('clients/edit.html', client=client)
|
||||
|
||||
# Update client
|
||||
client.name = name
|
||||
@@ -221,6 +283,8 @@ def edit_client(client_id):
|
||||
client.phone = phone
|
||||
client.address = address
|
||||
client.default_hourly_rate = default_hourly_rate
|
||||
client.prepaid_hours_monthly = prepaid_hours_monthly
|
||||
client.prepaid_reset_day = prepaid_reset_day
|
||||
client.updated_at = datetime.utcnow()
|
||||
|
||||
if not safe_commit('edit_client', {'client_id': client.id}):
|
||||
|
||||
+60
-23
@@ -10,6 +10,7 @@ import csv
|
||||
import json
|
||||
from app.utils.db import safe_commit
|
||||
from app.utils.excel_export import create_invoices_list_excel
|
||||
from app.utils.prepaid_hours import PrepaidHoursAllocator
|
||||
from app.utils.posthog_funnels import (
|
||||
track_invoice_page_viewed,
|
||||
track_invoice_project_selected,
|
||||
@@ -507,45 +508,54 @@ def generate_from_time(invoice_id):
|
||||
# Clear existing items
|
||||
invoice.items.delete()
|
||||
|
||||
total_prepaid_allocated = Decimal('0')
|
||||
prepaid_allocator = None
|
||||
|
||||
# Process time entries
|
||||
if selected_entries:
|
||||
# Group time entries by task/project and create invoice items
|
||||
time_entries = TimeEntry.query.filter(TimeEntry.id.in_(selected_entries)).all()
|
||||
|
||||
# Group by task (if available) or project
|
||||
|
||||
prepaid_allocator = PrepaidHoursAllocator(client=invoice.client, invoice=invoice)
|
||||
processed_entries = prepaid_allocator.process(time_entries)
|
||||
total_prepaid_allocated = prepaid_allocator.total_prepaid_hours_assigned
|
||||
|
||||
grouped_entries = {}
|
||||
for entry in time_entries:
|
||||
for processed in processed_entries:
|
||||
if processed.billable_hours <= 0:
|
||||
continue
|
||||
|
||||
entry = processed.entry
|
||||
if entry.task_id:
|
||||
key = f"task_{entry.task_id}"
|
||||
if key not in grouped_entries:
|
||||
grouped_entries[key] = {
|
||||
'description': f"Task: {entry.task.name if entry.task else 'Unknown Task'}",
|
||||
'entries': [],
|
||||
'total_hours': 0
|
||||
}
|
||||
description = f"Task: {entry.task.name if entry.task else 'Unknown Task'}"
|
||||
else:
|
||||
key = f"project_{entry.project_id}"
|
||||
if key not in grouped_entries:
|
||||
grouped_entries[key] = {
|
||||
'description': f"Project: {entry.project.name}",
|
||||
'entries': [],
|
||||
'total_hours': 0
|
||||
}
|
||||
|
||||
grouped_entries[key]['entries'].append(entry)
|
||||
grouped_entries[key]['total_hours'] += entry.duration_hours
|
||||
|
||||
description = f"Project: {entry.project.name}"
|
||||
|
||||
if key not in grouped_entries:
|
||||
grouped_entries[key] = {
|
||||
'description': description,
|
||||
'entries': [],
|
||||
'total_hours': Decimal('0'),
|
||||
}
|
||||
|
||||
grouped_entries[key]['entries'].append(processed)
|
||||
grouped_entries[key]['total_hours'] += processed.billable_hours
|
||||
|
||||
# Create invoice items from time entries
|
||||
for group in grouped_entries.values():
|
||||
# Resolve effective rate (project override -> project rate -> client default)
|
||||
if group['total_hours'] <= 0:
|
||||
continue
|
||||
|
||||
hourly_rate = RateOverride.resolve_rate(invoice.project)
|
||||
|
||||
|
||||
item = InvoiceItem(
|
||||
invoice_id=invoice.id,
|
||||
description=group['description'],
|
||||
quantity=group['total_hours'],
|
||||
unit_price=hourly_rate,
|
||||
time_entry_ids=','.join(str(entry.id) for entry in group['entries'])
|
||||
time_entry_ids=','.join(str(processed.entry.id) for processed in group['entries'])
|
||||
)
|
||||
db.session.add(item)
|
||||
|
||||
@@ -600,6 +610,13 @@ def generate_from_time(invoice_id):
|
||||
return redirect(url_for('invoices.edit_invoice', invoice_id=invoice.id))
|
||||
|
||||
flash('Invoice items generated successfully from time entries and costs', 'success')
|
||||
if total_prepaid_allocated and total_prepaid_allocated > 0:
|
||||
flash(
|
||||
_('Applied %(hours)s prepaid hours for %(client)s before billing overages.',
|
||||
hours=f"{total_prepaid_allocated:.2f}",
|
||||
client=invoice.client_name),
|
||||
'info'
|
||||
)
|
||||
return redirect(url_for('invoices.edit_invoice', invoice_id=invoice.id))
|
||||
|
||||
# GET request - show time entry and cost selection
|
||||
@@ -645,6 +662,23 @@ def generate_from_time(invoice_id):
|
||||
total_available_costs = sum(float(cost.amount) for cost in unbilled_costs)
|
||||
total_available_expenses = sum(float(expense.total_amount) for expense in unbilled_expenses)
|
||||
total_available_goods = sum(float(good.total_amount) for good in project_goods)
|
||||
|
||||
prepaid_summary = []
|
||||
prepaid_plan_hours = None
|
||||
if invoice.client and invoice.client.prepaid_plan_enabled:
|
||||
allocator = PrepaidHoursAllocator(client=invoice.client)
|
||||
summaries = allocator.build_summary(unbilled_entries)
|
||||
prepaid_summary = []
|
||||
for summary in summaries:
|
||||
allocation_month = summary.allocation_month
|
||||
prepaid_summary.append({
|
||||
'allocation_month': allocation_month,
|
||||
'allocation_month_label': allocation_month.strftime('%Y-%m-%d') if allocation_month else '',
|
||||
'plan_hours': float(summary.plan_hours),
|
||||
'consumed_hours': float(summary.consumed_hours),
|
||||
'remaining_hours': float(summary.remaining_hours)
|
||||
})
|
||||
prepaid_plan_hours = float(invoice.client.prepaid_hours_decimal)
|
||||
|
||||
# Get currency from settings
|
||||
settings = Settings.get_settings()
|
||||
@@ -660,7 +694,10 @@ def generate_from_time(invoice_id):
|
||||
total_available_costs=total_available_costs,
|
||||
total_available_expenses=total_available_expenses,
|
||||
total_available_goods=total_available_goods,
|
||||
currency=currency)
|
||||
currency=currency,
|
||||
prepaid_summary=prepaid_summary,
|
||||
prepaid_plan_hours=prepaid_plan_hours,
|
||||
prepaid_reset_day=invoice.client.prepaid_reset_day if invoice.client else None)
|
||||
|
||||
@invoices_bp.route('/invoices/<int:invoice_id>/export/csv')
|
||||
@login_required
|
||||
|
||||
@@ -587,6 +587,10 @@ class EnhancedErrorHandler {
|
||||
}
|
||||
|
||||
handleJavaScriptError(error, message, filename, lineno) {
|
||||
if (this.shouldIgnoreFrontendNoise(error, message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userFriendlyMessage = 'An unexpected error occurred. Please refresh the page or contact support if the problem persists.';
|
||||
|
||||
this.showError(userFriendlyMessage, 'Application Error');
|
||||
@@ -601,6 +605,10 @@ class EnhancedErrorHandler {
|
||||
}
|
||||
|
||||
handleUnhandledRejection(reason) {
|
||||
if (this.shouldIgnoreFrontendNoise(reason, reason?.message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userFriendlyMessage = 'An operation failed unexpectedly. Please try again or contact support if the problem persists.';
|
||||
|
||||
this.showError(userFriendlyMessage, 'Operation Failed');
|
||||
@@ -667,6 +675,19 @@ class EnhancedErrorHandler {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
shouldIgnoreFrontendNoise(error, message) {
|
||||
const normalizedMessage = String(message || error?.message || '').toLowerCase();
|
||||
|
||||
// Known benign ResizeObserver warning triggered by various UI libraries/browsers
|
||||
if (normalizedMessage.includes('resizeobserver loop limit exceeded') ||
|
||||
normalizedMessage.includes('resizeobserver loop completed with undelivered notifications')) {
|
||||
console.debug('Ignored benign ResizeObserver warning:', message || error);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize enhanced error handler
|
||||
|
||||
@@ -73,5 +73,7 @@
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/js/all.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='toast-notifications.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='toast-manager.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='error-handling-enhanced.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -51,6 +51,19 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="prepaid_hours_monthly" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Monthly Prepaid Hours') }}</label>
|
||||
<input type="number" step="0.25" min="0" id="prepaid_hours_monthly" name="prepaid_hours_monthly" value="{{ request.form.get('prepaid_hours_monthly','') }}" placeholder="{{ _('e.g. 50') }}" class="form-input">
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Leave empty if this client has no prepaid allocation.') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="prepaid_reset_day" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Prepaid Reset Day') }}</label>
|
||||
<input type="number" min="1" max="28" id="prepaid_reset_day" name="prepaid_reset_day" value="{{ request.form.get('prepaid_reset_day','1') }}" class="form-input">
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Day of the month when prepaid hours reset (1-28).') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="phone" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Phone') }}</label>
|
||||
@@ -86,6 +99,10 @@
|
||||
<strong>{{ _('Contact Information') }}</strong>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Add contact details for easy communication and record keeping.') }}</p>
|
||||
</li>
|
||||
<li>
|
||||
<strong>{{ _('Prepaid Hours') }}</strong>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Configure monthly included hours and the reset day if this client has a retainer or prepaid bundle.') }}</p>
|
||||
</li>
|
||||
<li>
|
||||
<strong>{{ _('Description') }}</strong>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Provide context about the client relationship or typical project types.') }}</p>
|
||||
|
||||
@@ -34,6 +34,19 @@
|
||||
<textarea id="description" name="description" rows="3" placeholder="{{ _('Brief description of the client or project scope') }}" class="form-input">{{ request.form.get('description', client.description or '') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="prepaid_hours_monthly" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Monthly Prepaid Hours') }}</label>
|
||||
<input type="number" step="0.25" min="0" id="prepaid_hours_monthly" name="prepaid_hours_monthly" value="{{ request.form.get('prepaid_hours_monthly', client.prepaid_hours_monthly or '') }}" placeholder="{{ _('e.g. 50') }}" class="form-input">
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Leave empty if this client has no prepaid allocation.') }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="prepaid_reset_day" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Prepaid Reset Day') }}</label>
|
||||
<input type="number" min="1" max="28" id="prepaid_reset_day" name="prepaid_reset_day" value="{{ request.form.get('prepaid_reset_day', client.prepaid_reset_day or 1) }}" class="form-input">
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Day of the month when prepaid hours reset (1-28).') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="contact_person" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Contact Person') }}</label>
|
||||
|
||||
@@ -60,6 +60,28 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if prepaid_overview %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-lg font-semibold mb-4">{{ _('Prepaid Hours') }}</h2>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-3">
|
||||
{{ _('Plan includes %(hours)s hours per cycle. Resets on day %(day)s.', hours='%.2f'|format(prepaid_overview.plan_hours), day=client.prepaid_reset_day) }}
|
||||
</p>
|
||||
<ul class="space-y-2 text-sm text-text-light dark:text-text-dark">
|
||||
<li>
|
||||
<span class="font-medium">{{ _('Current cycle start') }}:</span>
|
||||
<span>{{ prepaid_overview.month_label }}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="font-medium text-emerald-600">{{ _('Remaining hours') }}:</span>
|
||||
<span>{{ '%.2f'|format(prepaid_overview.remaining_hours) }} {{ _('h') }}</span>
|
||||
</li>
|
||||
<li>
|
||||
<span class="font-medium text-amber-600">{{ _('Consumed this cycle') }}:</span>
|
||||
<span>{{ '%.2f'|format(prepaid_overview.consumed_hours) }} {{ _('h') }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Projects -->
|
||||
|
||||
@@ -116,6 +116,26 @@
|
||||
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Total available costs') }}: <span class="font-semibold text-text-light dark:text-text-dark">{{ '%.2f'|format(total_available_costs) }} {{ currency }}</span></div>
|
||||
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Total available expenses') }}: <span class="font-semibold text-text-light dark:text-text-dark">{{ '%.2f'|format(total_available_expenses) }} {{ currency }}</span></div>
|
||||
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Total available goods') }}: <span class="font-semibold text-text-light dark:text-text-dark">{{ '%.2f'|format(total_available_goods) }} {{ currency }}</span></div>
|
||||
{% if prepaid_plan_hours %}
|
||||
<div class="mt-4 pt-4 border-t border-border-light dark:border-border-dark">
|
||||
<h4 class="text-sm font-semibold text-text-light dark:text-text-dark mb-2">{{ _('Prepaid Hours Overview') }}</h4>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mb-2">
|
||||
{{ _('Plan includes %(hours)s hours per cycle (resets on day %(day)s).', hours='%.2f'|format(prepaid_plan_hours), day=prepaid_reset_day) }}
|
||||
</p>
|
||||
{% if prepaid_summary %}
|
||||
<ul class="space-y-1 text-xs text-text-muted-light dark:text-text-muted-dark">
|
||||
{% for item in prepaid_summary %}
|
||||
<li class="flex justify-between gap-4">
|
||||
<span class="font-medium text-text-light dark:text-text-dark">{{ item.allocation_month_label }}</span>
|
||||
<span>{{ _('Consumed: %(consumed)s h • Remaining: %(remaining)s h', consumed='%.2f'|format(item.consumed_hours), remaining='%.2f'|format(item.remaining_hours)) }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% else %}
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('No prepaid usage recorded for selected period yet.') }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
|
||||
@@ -186,12 +186,12 @@
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="p-4">
|
||||
<td class="p-4 relative overflow-visible">
|
||||
<div class="relative inline-block">
|
||||
<button type="button" onclick="toggleInvoiceActions({{ invoice.id }})" class="text-primary hover:text-primary/80 font-medium">
|
||||
<button type="button" onclick="toggleInvoiceActions(event, {{ invoice.id }})" class="text-primary hover:text-primary/80 font-medium">
|
||||
Actions <i class="fas fa-chevron-down ml-1 text-xs"></i>
|
||||
</button>
|
||||
<div id="invoiceActions{{ invoice.id }}" class="hidden absolute right-0 mt-2 w-48 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg z-50">
|
||||
<div id="invoiceActions{{ invoice.id }}" class="invoice-actions-dropdown hidden absolute right-0 mt-2 w-48 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg" style="z-index: 1200;">
|
||||
<div class="py-1">
|
||||
<a href="{{ url_for('invoices.view_invoice', invoice_id=invoice.id) }}" class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||
<i class="fas fa-eye mr-2"></i>View
|
||||
@@ -362,32 +362,85 @@ function filterByStatus(status) {
|
||||
}
|
||||
}
|
||||
|
||||
function resetInvoiceActionsMenu(menu) {
|
||||
if (!menu) return;
|
||||
menu.classList.add('hidden');
|
||||
menu.style.position = '';
|
||||
menu.style.left = '';
|
||||
menu.style.top = '';
|
||||
menu.style.bottom = '';
|
||||
menu.style.right = '';
|
||||
menu.style.zIndex = '';
|
||||
delete menu.dataset.visible;
|
||||
}
|
||||
|
||||
function closeInvoiceActionMenus() {
|
||||
document.querySelectorAll('.invoice-actions-dropdown').forEach(resetInvoiceActionsMenu);
|
||||
}
|
||||
|
||||
// Toggle invoice actions dropdown
|
||||
function toggleInvoiceActions(invoiceId) {
|
||||
const menu = document.getElementById('invoiceActions' + invoiceId);
|
||||
const allMenus = document.querySelectorAll('[id^="invoiceActions"]');
|
||||
|
||||
// Close all other menus
|
||||
allMenus.forEach(m => {
|
||||
if (m.id !== 'invoiceActions' + invoiceId) {
|
||||
m.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle current menu
|
||||
if (menu) {
|
||||
menu.classList.toggle('hidden');
|
||||
function toggleInvoiceActions(evt, invoiceId) {
|
||||
if (evt) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
}
|
||||
|
||||
const menu = document.getElementById('invoiceActions' + invoiceId);
|
||||
if (!menu) return;
|
||||
|
||||
const willOpen = menu.classList.contains('hidden');
|
||||
|
||||
closeInvoiceActionMenus();
|
||||
|
||||
if (!willOpen) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trigger = (evt && evt.currentTarget) || document.querySelector(`[data-invoice-actions-trigger="${invoiceId}"]`);
|
||||
if (!trigger) return;
|
||||
|
||||
menu.classList.remove('hidden');
|
||||
menu.style.position = 'fixed';
|
||||
menu.style.zIndex = '1300';
|
||||
|
||||
// Measure after making visible
|
||||
const rect = trigger.getBoundingClientRect();
|
||||
const menuWidth = menu.offsetWidth;
|
||||
const menuHeight = menu.offsetHeight;
|
||||
|
||||
let left = rect.right - menuWidth;
|
||||
if (left < 16) {
|
||||
left = rect.left;
|
||||
}
|
||||
if (left + menuWidth > window.innerWidth - 16) {
|
||||
left = window.innerWidth - menuWidth - 16;
|
||||
}
|
||||
if (left < 16) {
|
||||
left = 16;
|
||||
}
|
||||
|
||||
let top = rect.bottom + 8;
|
||||
if (top + menuHeight > window.innerHeight - 16) {
|
||||
top = rect.top - menuHeight - 8;
|
||||
}
|
||||
if (top < 16) {
|
||||
top = Math.max(16, rect.bottom + 8);
|
||||
}
|
||||
|
||||
menu.style.left = `${left}px`;
|
||||
menu.style.top = `${top}px`;
|
||||
menu.dataset.visible = 'true';
|
||||
}
|
||||
|
||||
// Close dropdowns when clicking outside
|
||||
document.addEventListener('click', function(e) {
|
||||
if (!e.target.closest('[onclick*="toggleInvoiceActions"]') && !e.target.closest('[id^="invoiceActions"]')) {
|
||||
document.querySelectorAll('[id^="invoiceActions"]').forEach(menu => {
|
||||
menu.classList.add('hidden');
|
||||
});
|
||||
if (!e.target.closest('[onclick*="toggleInvoiceActions"]') && !e.target.closest('.invoice-actions-dropdown')) {
|
||||
closeInvoiceActionMenus();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('scroll', closeInvoiceActionMenus, true);
|
||||
|
||||
function toggleFilterVisibility() {
|
||||
const filterBody = document.getElementById('filterBody');
|
||||
const toggleIcon = document.getElementById('filterToggleIcon');
|
||||
|
||||
@@ -152,6 +152,8 @@
|
||||
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.2/js/all.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='toast-notifications.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='toast-manager.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='error-handling-enhanced.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, date, timedelta
|
||||
from decimal import Decimal, ROUND_HALF_UP
|
||||
from typing import Iterable, List, Optional, Set
|
||||
|
||||
from app import db
|
||||
from app.models import Client, TimeEntry, ClientPrepaidConsumption, Invoice
|
||||
|
||||
SECONDS_IN_HOUR = Decimal('3600')
|
||||
TWO_DECIMALS = Decimal('0.01')
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProcessedTimeEntry:
|
||||
"""Result container describing how a time entry was treated."""
|
||||
|
||||
entry: TimeEntry
|
||||
billable_hours: Decimal
|
||||
prepaid_hours: Decimal
|
||||
allocation_month: Optional[date]
|
||||
|
||||
|
||||
@dataclass
|
||||
class PrepaidMonthSummary:
|
||||
"""Summary of prepaid plan usage for a given cycle."""
|
||||
|
||||
allocation_month: date
|
||||
plan_hours: Decimal
|
||||
consumed_hours: Decimal
|
||||
remaining_hours: Decimal
|
||||
|
||||
|
||||
class PrepaidHoursAllocator:
|
||||
"""Encapsulates prepaid hour allocation logic for a client."""
|
||||
|
||||
def __init__(self, client: Client, invoice: Optional[Invoice] = None):
|
||||
self.client = client
|
||||
self.invoice = invoice
|
||||
self.plan_hours = client.prepaid_hours_decimal if client else Decimal('0')
|
||||
self.total_prepaid_hours_assigned = Decimal('0')
|
||||
self._consumed_by_period: dict[date, Decimal] = {}
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Public API
|
||||
# ----------------------------------------------------------------------
|
||||
def process(self, entries: Iterable[TimeEntry]) -> List[ProcessedTimeEntry]:
|
||||
"""Allocate prepaid hours for the provided time entries."""
|
||||
entries = list(entries or [])
|
||||
|
||||
if not entries:
|
||||
return []
|
||||
|
||||
# Always work against a deterministic ordering
|
||||
entries.sort(key=lambda e: (e.start_time or datetime.min))
|
||||
|
||||
if not self.client or not self.client.prepaid_plan_enabled:
|
||||
return [
|
||||
ProcessedTimeEntry(
|
||||
entry=entry,
|
||||
billable_hours=self._hours_from_entry(entry),
|
||||
prepaid_hours=Decimal('0'),
|
||||
allocation_month=self._allocation_month(entry)
|
||||
)
|
||||
for entry in entries
|
||||
]
|
||||
|
||||
self._reset_invoice_allocations()
|
||||
months = self._collect_months(entries)
|
||||
self._load_existing_consumption(months)
|
||||
|
||||
processed: List[ProcessedTimeEntry] = []
|
||||
|
||||
for entry in entries:
|
||||
hours = self._hours_from_entry(entry)
|
||||
allocation_month = self._allocation_month(entry)
|
||||
|
||||
if hours <= 0 or allocation_month is None:
|
||||
processed.append(
|
||||
ProcessedTimeEntry(
|
||||
entry=entry,
|
||||
billable_hours=Decimal('0'),
|
||||
prepaid_hours=Decimal('0'),
|
||||
allocation_month=allocation_month
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
remaining = self._remaining_allowance(allocation_month)
|
||||
prepaid_hours = self._quantize_hours(min(hours, remaining) if remaining > 0 else Decimal('0'))
|
||||
billable_hours = self._quantize_hours(hours - prepaid_hours)
|
||||
|
||||
if prepaid_hours > 0:
|
||||
self._record_consumption(entry, allocation_month, prepaid_hours)
|
||||
entry.billable = billable_hours > 0
|
||||
else:
|
||||
entry.billable = True
|
||||
|
||||
processed.append(
|
||||
ProcessedTimeEntry(
|
||||
entry=entry,
|
||||
billable_hours=billable_hours,
|
||||
prepaid_hours=prepaid_hours,
|
||||
allocation_month=allocation_month
|
||||
)
|
||||
)
|
||||
|
||||
self.total_prepaid_hours_assigned = sum((item.prepaid_hours for item in processed), Decimal('0'))
|
||||
return processed
|
||||
|
||||
def build_summary(self, entries: Iterable[TimeEntry]) -> List[PrepaidMonthSummary]:
|
||||
"""Return prepaid period summaries for UI purposes (no DB mutations)."""
|
||||
if not self.client or not self.client.prepaid_plan_enabled:
|
||||
return []
|
||||
|
||||
entries = list(entries or [])
|
||||
months = self._collect_months(entries)
|
||||
if not months:
|
||||
return []
|
||||
|
||||
# Reset local cache and load existing consumption without mutating data
|
||||
self._consumed_by_period = {}
|
||||
self._load_existing_consumption(months)
|
||||
|
||||
summaries: List[PrepaidMonthSummary] = []
|
||||
for month in sorted(months):
|
||||
consumed = self._quantize_hours(self._consumed_by_period.get(month, Decimal('0')))
|
||||
remaining = self._quantize_hours(self.plan_hours - consumed)
|
||||
if remaining < 0:
|
||||
remaining = Decimal('0').quantize(TWO_DECIMALS)
|
||||
summaries.append(
|
||||
PrepaidMonthSummary(
|
||||
allocation_month=month,
|
||||
plan_hours=self._quantize_hours(self.plan_hours),
|
||||
consumed_hours=consumed,
|
||||
remaining_hours=remaining
|
||||
)
|
||||
)
|
||||
return summaries
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Internal helpers
|
||||
# ----------------------------------------------------------------------
|
||||
def _reset_invoice_allocations(self):
|
||||
"""Remove existing ledger rows tied to the invoice before recalculating."""
|
||||
if not self.invoice:
|
||||
return
|
||||
|
||||
existing_allocations = ClientPrepaidConsumption.query.filter_by(invoice_id=self.invoice.id).all()
|
||||
if not existing_allocations:
|
||||
return
|
||||
|
||||
entry_ids = [allocation.time_entry_id for allocation in existing_allocations]
|
||||
if entry_ids:
|
||||
entries = TimeEntry.query.filter(TimeEntry.id.in_(entry_ids)).all()
|
||||
for entry in entries:
|
||||
entry.billable = True
|
||||
|
||||
ClientPrepaidConsumption.query.filter_by(invoice_id=self.invoice.id).delete(synchronize_session=False)
|
||||
db.session.flush()
|
||||
|
||||
def _collect_months(self, entries: Iterable[TimeEntry]) -> Set[date]:
|
||||
months: Set[date] = set()
|
||||
for entry in entries:
|
||||
allocation_month = self._allocation_month(entry)
|
||||
if allocation_month:
|
||||
months.add(allocation_month)
|
||||
return months
|
||||
|
||||
def _load_existing_consumption(self, months: Set[date]):
|
||||
if not months:
|
||||
return
|
||||
|
||||
query = ClientPrepaidConsumption.query.filter(
|
||||
ClientPrepaidConsumption.client_id == self.client.id,
|
||||
ClientPrepaidConsumption.allocation_month.in_(months)
|
||||
)
|
||||
|
||||
if self.invoice:
|
||||
query = query.filter(ClientPrepaidConsumption.invoice_id != self.invoice.id)
|
||||
|
||||
for row in query:
|
||||
hours = Decimal(row.seconds_consumed or 0) / SECONDS_IN_HOUR
|
||||
month = row.allocation_month
|
||||
self._consumed_by_period[month] = self._consumed_by_period.get(month, Decimal('0')) + hours
|
||||
|
||||
def _allocation_month(self, entry: TimeEntry) -> Optional[date]:
|
||||
if not entry or not entry.start_time:
|
||||
return None
|
||||
return self.client.prepaid_month_start(entry.start_time)
|
||||
|
||||
def _remaining_allowance(self, month: date) -> Decimal:
|
||||
consumed = self._consumed_by_period.get(month, Decimal('0'))
|
||||
remaining = self.plan_hours - consumed
|
||||
return remaining if remaining > 0 else Decimal('0')
|
||||
|
||||
def _record_consumption(self, entry: TimeEntry, month: date, prepaid_hours: Decimal):
|
||||
if prepaid_hours <= 0:
|
||||
return
|
||||
|
||||
seconds = int((prepaid_hours * SECONDS_IN_HOUR).quantize(Decimal('1'), rounding=ROUND_HALF_UP))
|
||||
consumption = ClientPrepaidConsumption(
|
||||
client_id=self.client.id,
|
||||
time_entry_id=entry.id,
|
||||
invoice_id=self.invoice.id if self.invoice else None,
|
||||
allocation_month=month,
|
||||
seconds_consumed=seconds
|
||||
)
|
||||
db.session.add(consumption)
|
||||
|
||||
# Update cache to reflect newly allocated hours
|
||||
self._consumed_by_period[month] = self._consumed_by_period.get(month, Decimal('0')) + prepaid_hours
|
||||
|
||||
@staticmethod
|
||||
def _hours_from_entry(entry: TimeEntry) -> Decimal:
|
||||
duration_seconds = entry.duration_seconds or 0
|
||||
return (Decimal(duration_seconds) / SECONDS_IN_HOUR).quantize(TWO_DECIMALS)
|
||||
|
||||
@staticmethod
|
||||
def _quantize_hours(value: Decimal) -> Decimal:
|
||||
if value is None:
|
||||
return Decimal('0').quantize(TWO_DECIMALS)
|
||||
return value.quantize(TWO_DECIMALS)
|
||||
|
||||
@@ -114,3 +114,6 @@
|
||||
{"asctime": "2025-10-30 09:45:46,439", "levelname": "INFO", "name": "timetracker", "message": "task.deleted", "taskName": null, "request_id": "87a0ad0f-3147-49be-850a-048e28fe9887", "event": "task.deleted", "user_id": 1, "task_id": 1, "project_id": 1}
|
||||
{"asctime": "2025-10-30 09:45:46,455", "levelname": "INFO", "name": "timetracker", "message": "task.deleted", "taskName": null, "request_id": "87a0ad0f-3147-49be-850a-048e28fe9887", "event": "task.deleted", "user_id": 1, "task_id": 2, "project_id": 1}
|
||||
{"asctime": "2025-10-30 09:45:46,461", "levelname": "INFO", "name": "timetracker", "message": "task.deleted", "taskName": null, "request_id": "87a0ad0f-3147-49be-850a-048e28fe9887", "event": "task.deleted", "user_id": 1, "task_id": 3, "project_id": 1}
|
||||
{"asctime": "2025-11-12 07:38:50,936", "levelname": "INFO", "name": "timetracker", "message": "client.updated", "taskName": null, "request_id": "e21141a4-90e7-4fcb-bc56-e74d85d8e21b", "event": "client.updated", "user_id": 1, "client_id": 1}
|
||||
{"asctime": "2025-11-12 07:40:03,232", "levelname": "INFO", "name": "timetracker", "message": "client.updated", "taskName": null, "request_id": "347bcc59-a8a4-4d93-9e9c-454f3e215dbf", "event": "client.updated", "user_id": 1, "client_id": 1}
|
||||
{"asctime": "2025-11-12 07:40:35,204", "levelname": "INFO", "name": "timetracker", "message": "client.updated", "taskName": null, "request_id": "8df6eb1f-67b4-44ac-ae77-a2342ba55880", "event": "client.updated", "user_id": 1, "client_id": 1}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
"""Add client prepaid hours support and consumption ledger
|
||||
|
||||
Revision ID: 042_client_prepaid_hours
|
||||
Revises: 041_add_invoice_pdf_templates
|
||||
Create Date: 2025-11-11
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '042_client_prepaid_hours'
|
||||
down_revision = '041_add_invoice_pdf_templates'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""Add prepaid hours configuration and ledger tracking."""
|
||||
with op.batch_alter_table('clients', schema=None) as batch_op:
|
||||
batch_op.add_column(sa.Column('prepaid_hours_monthly', sa.Numeric(7, 2), nullable=True))
|
||||
batch_op.add_column(sa.Column('prepaid_reset_day', sa.Integer(), nullable=False, server_default='1'))
|
||||
|
||||
op.create_table(
|
||||
'client_prepaid_consumptions',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('client_id', sa.Integer(), nullable=False),
|
||||
sa.Column('time_entry_id', sa.Integer(), nullable=False),
|
||||
sa.Column('invoice_id', sa.Integer(), nullable=True),
|
||||
sa.Column('allocation_month', sa.Date(), nullable=False),
|
||||
sa.Column('seconds_consumed', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.func.now(), onupdate=sa.func.now()),
|
||||
sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ),
|
||||
sa.ForeignKeyConstraint(['time_entry_id'], ['time_entries.id'], ),
|
||||
sa.ForeignKeyConstraint(['invoice_id'], ['invoices.id'], ),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('time_entry_id', name='uq_client_prepaid_consumptions_time_entry_id')
|
||||
)
|
||||
op.create_index(
|
||||
'ix_client_prepaid_consumptions_client_month',
|
||||
'client_prepaid_consumptions',
|
||||
['client_id', 'allocation_month'],
|
||||
unique=False
|
||||
)
|
||||
op.create_index(
|
||||
'ix_client_prepaid_consumptions_invoice_id',
|
||||
'client_prepaid_consumptions',
|
||||
['invoice_id'],
|
||||
unique=False
|
||||
)
|
||||
|
||||
# Remove server default now that existing rows are backfilled
|
||||
with op.batch_alter_table('clients', schema=None) as batch_op:
|
||||
batch_op.alter_column('prepaid_reset_day', server_default=None)
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""Revert prepaid hours schema changes."""
|
||||
op.drop_index('ix_client_prepaid_consumptions_invoice_id', table_name='client_prepaid_consumptions')
|
||||
op.drop_index('ix_client_prepaid_consumptions_client_month', table_name='client_prepaid_consumptions')
|
||||
op.drop_table('client_prepaid_consumptions')
|
||||
|
||||
with op.batch_alter_table('clients', schema=None) as batch_op:
|
||||
batch_op.drop_column('prepaid_reset_day')
|
||||
batch_op.drop_column('prepaid_hours_monthly')
|
||||
|
||||
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