Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Architecture refactor** — API v1 split into per-resource sub-blueprints (projects, tasks, clients, invoices, expenses, payments, mileage, deals, leads, contacts) under `app/routes/api_v1_*.py`; bootstrap slimmed by moving `setup_logging` to `app/utils/setup_logging.py` and legacy migrations to `app/utils/legacy_migrations.py`. Dashboard aggregations (top projects, time-by-project chart) moved into `AnalyticsService` (`get_dashboard_top_projects`, `get_time_by_project_chart`); dashboard route simplified to call services only. ARCHITECTURE.md updated with module table, API structure, and data flow; DEVELOPMENT.md with development workflow and build steps.

### Fixed
- **Xero integration for apps created after March 2026 (Issue #567)** — OAuth no longer fails with "Invalid scope for client" for Xero Developer apps created on or after March 2, 2026. Replaced deprecated `accounting.transactions` scope with granular `accounting.invoices` and `accounting.payments`. Expense sync now uses the correct `/api.xro/2.0/ExpenseClaims` endpoint (replacing the non-existent `/api.xro/2.0/Expenses`) and reads `ExpenseClaimID` from the response. `_api_request` now accepts an optional request body so invoice and expense payloads are sent to the Xero API. See [docs/integrations/XERO.md](docs/integrations/XERO.md).
- **Time Entries date filter and export (Issue #555)** — Start/End date filters were hard to discover and exports ignored them. The Time Entries overview now has a visible **Apply filters** button in the filter header (next to Clear Filters and Export) so users can apply date and other filters without scrolling. CSV and PDF export links always use the current filter parameters: export href is set from the page URL on load and updated whenever filter form values change, so left-click export, right-click "Open in new tab", and "Save link as" all produce filtered exports. The in-form Apply filters button and the header button both trigger the same filter logic; clicking the header button expands the filter panel if it is collapsed.
- **Log Time / Edit Time Entry on mobile (Issue #557)** — Opening the manual time entry ("Log Time") or edit time entry page on mobile could freeze or crash the browser. The Toast UI Editor (WYSIWYG markdown editor) for the notes field is heavy and causes freezes on mobile Safari/Chrome. On viewports ≤767px we now skip loading the editor and show a plain textarea for notes instead; desktop behavior is unchanged. Manual entry and edit timer templates load Toast UI only when not in mobile view.
- **Stop & Save error (Issue #563)** — Fixed error after clicking "Stop & Save" on the dashboard. The post-timer toast was building the "View time entries" URL with the wrong route name (`timer.time_entries`); the correct endpoint is `timer.time_entries_overview`. Time entries were already saved; the error occurred when rendering the dashboard redirect.
Expand Down
21 changes: 14 additions & 7 deletions app/integrations/xero.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ def get_authorization_url(self, redirect_uri: str, state: str = None) -> str:
if not client_id:
raise ValueError("XERO_CLIENT_ID not configured")

scopes = ["accounting.transactions", "accounting.contacts", "accounting.settings", "offline_access"]
scopes = ["accounting.invoices", "accounting.payments", "accounting.contacts", "accounting.settings", "offline_access"]

auth_url = "https://login.xero.com/identity/connect/authorize"
params = {
Expand Down Expand Up @@ -180,7 +180,14 @@ def test_connection(self) -> Dict[str, Any]:
except Exception as e:
return {"success": False, "message": f"Connection test failed: {str(e)}"}

def _api_request(self, method: str, endpoint: str, access_token: str, tenant_id: str) -> Optional[Dict]:
def _api_request(
self,
method: str,
endpoint: str,
access_token: str,
tenant_id: str,
json_body: Optional[Dict] = None,
) -> Optional[Dict]:
"""Make API request to Xero"""
url = f"{self.BASE_URL}{endpoint}"

Expand All @@ -195,7 +202,7 @@ def _api_request(self, method: str, endpoint: str, access_token: str, tenant_id:
if method.upper() == "GET":
response = requests.get(url, headers=headers, timeout=10)
elif method.upper() == "POST":
response = requests.post(url, headers=headers, timeout=10, json={})
response = requests.post(url, headers=headers, timeout=10, json=json_body or {})
else:
response = requests.request(method, url, headers=headers, timeout=10)

Expand Down Expand Up @@ -251,7 +258,7 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]:
if xero_expense:
if not hasattr(expense, "metadata") or not expense.metadata:
expense.metadata = {}
expense.metadata["xero_expense_id"] = xero_expense.get("Expenses", [{}])[0].get("ExpenseID")
expense.metadata["xero_expense_id"] = xero_expense.get("ExpenseClaims", [{}])[0].get("ExpenseClaimID")
synced_count += 1
except Exception as e:
errors.append(f"Error syncing expense {expense.id}: {str(e)}")
Expand Down Expand Up @@ -316,7 +323,7 @@ def _create_xero_invoice(self, invoice, access_token: str, tenant_id: str) -> Op
xero_invoice["LineItems"].append(line_item)

endpoint = "/api.xro/2.0/Invoices"
return self._api_request("POST", endpoint, access_token, tenant_id)
return self._api_request("POST", endpoint, access_token, tenant_id, json_body={"Invoices": [xero_invoice]})

def _create_xero_expense(self, expense, access_token: str, tenant_id: str) -> Optional[Dict]:
"""Create expense in Xero"""
Expand Down Expand Up @@ -346,8 +353,8 @@ def _create_xero_expense(self, expense, access_token: str, tenant_id: str) -> Op
],
}

endpoint = "/api.xro/2.0/Expenses"
return self._api_request("POST", endpoint, access_token, tenant_id)
endpoint = "/api.xro/2.0/ExpenseClaims"
return self._api_request("POST", endpoint, access_token, tenant_id, json_body={"ExpenseClaims": [xero_expense]})

def get_config_schema(self) -> Dict[str, Any]:
"""Get configuration schema."""
Expand Down
39 changes: 31 additions & 8 deletions app/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -758,15 +758,38 @@ def oidc_callback():
try:
token = client.authorize_access_token()
except Exception as token_err:
current_app.logger.warning(
"OIDC token exchange failed (state/code_verifier mismatch or invalid code). "
"Session may have been lost between redirect and callback – check cookie size, domain, Secure, SameSite and proxy headers: %s",
token_err,
)
current_app.logger.info("OIDC callback redirect to login: reason=token_exchange_failed")
flash(
_("SSO failed. If this repeats, check session cookie and proxy configuration."), "error"
err_str = str(token_err).lower()
err_type_name = type(token_err).__name__
is_algorithm_or_jwe = (
"unsupported_algorithm" in err_str
or "unsupportedalgorithmerror" in err_type_name.lower()
or "jwe" in err_str
or "authlib.jose" in (getattr(token_err, "__module__", "") or "")
)
if is_algorithm_or_jwe:
current_app.logger.warning(
"OIDC token exchange failed: unsupported token algorithm or encrypted ID token (JWE). "
"IdP may have ID token encryption enabled: %s",
token_err,
)
current_app.logger.info("OIDC callback redirect to login: reason=unsupported_algorithm_or_jwe")
flash(
_(
"SSO failed: encrypted or unsupported ID tokens. "
"Disable ID token encryption on your provider (e.g. in Authentik, leave the Encryption Key empty)."
),
"error",
)
else:
current_app.logger.warning(
"OIDC token exchange failed (state/code_verifier mismatch or invalid code). "
"Session may have been lost between redirect and callback – check cookie size, domain, Secure, SameSite and proxy headers: %s",
token_err,
)
current_app.logger.info("OIDC callback redirect to login: reason=token_exchange_failed")
flash(
_("SSO failed. If this repeats, check session cookie and proxy configuration."), "error"
)
return redirect(url_for("auth.login"))

current_app.logger.info(
Expand Down
37 changes: 34 additions & 3 deletions app/templates/inventory/movements/form.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,8 @@
data-hint-devaluation="{{ _('Quantity to revalue (positive)') }}"
data-hint-default="{{ _('Use positive values for additions, negative for removals') }}"
data-msg-return-positive="{{ _('Return requires a positive quantity.') }}"
data-msg-waste-negative="{{ _('Waste requires a negative quantity.') }}">
data-msg-waste-negative="{{ _('Waste requires a negative quantity.') }}"
data-msg-devaluation-unsupported="{{ _('Devaluation requires a trackable item with a default cost.') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="space-y-6">
<div>
Expand All @@ -44,7 +45,7 @@
<select name="stock_item_id" id="stock_item_id" class="form-input" required>
<option value="">{{ _('Select Item') }}</option>
{% for item in stock_items %}
<option value="{{ item.id }}">{{ item.sku }} - {{ item.name }}</option>
<option value="{{ item.id }}" data-devaluation-supported="{{ 'true' if (item.is_trackable and item.default_cost is not none and (item.default_cost|float) > 0) else 'false' }}">{{ item.sku }} - {{ item.name }}</option>
{% endfor %}
</select>
</div>
Expand All @@ -66,6 +67,7 @@

<!-- Devaluation options (Return / Waste / Devaluation) -->
<div id="devaluation_section" class="hidden border border-gray-200 dark:border-gray-700 rounded-lg p-4">
<p class="text-sm text-amber-600 dark:text-amber-400 hidden mt-0 mb-2" id="devaluation_unsupported_msg" role="alert"></p>
<div class="flex items-center justify-between">
<div>
<h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ _('Devaluation') }}</h3>
Expand Down Expand Up @@ -133,6 +135,14 @@ <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ _('Devalua
const devaluePercent = document.getElementById('devalue_percent');
const devalueUnitCost = document.getElementById('devalue_unit_cost');
const discoveryHint = document.getElementById('devaluation_discovery_hint');
const unsupportedMsg = document.getElementById('devaluation_unsupported_msg');

function selectedItemSupportsDevaluation() {
const sel = document.getElementById('stock_item_id');
const opt = sel.options[sel.selectedIndex];
if (!opt || !opt.value) return true;
return opt.getAttribute('data-devaluation-supported') === 'true';
}

function refreshVisibility() {
const type = (movementType.value || '').toLowerCase();
Expand All @@ -142,14 +152,34 @@ <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ _('Devalua
const showDiscoveryHint = (type === 'return' || type === 'waste');
if (discoveryHint) discoveryHint.classList.toggle('hidden', !showDiscoveryHint);

const canDevalue = selectedItemSupportsDevaluation();
const isReturnOrWaste = (type === 'return' || type === 'waste');

if (type === 'devaluation') {
enabled.checked = true;
enabled.disabled = true;
if (unsupportedMsg) {
if (!canDevalue) {
unsupportedMsg.textContent = form.dataset.msgDevaluationUnsupported || 'Devaluation requires a trackable item with a default cost.';
unsupportedMsg.classList.remove('hidden');
} else {
unsupportedMsg.classList.add('hidden');
unsupportedMsg.textContent = '';
}
}
} else if (isReturnOrWaste && !canDevalue) {
enabled.checked = false;
enabled.disabled = true;
if (unsupportedMsg) {
unsupportedMsg.textContent = form.dataset.msgDevaluationUnsupported || 'Devaluation requires a trackable item with a default cost.';
unsupportedMsg.classList.remove('hidden');
}
} else {
enabled.disabled = false;
if (unsupportedMsg) { unsupportedMsg.classList.add('hidden'); unsupportedMsg.textContent = ''; }
}

const showFields = showSection && enabled.checked;
const showFields = showSection && enabled.checked && !enabled.disabled;
fields.classList.toggle('hidden', !showFields);

const m = (method.value || 'percent');
Expand Down Expand Up @@ -185,6 +215,7 @@ <h3 class="text-sm font-semibold text-gray-800 dark:text-gray-200">{{ _('Devalua
});

movementType.addEventListener('change', refreshVisibility);
document.getElementById('stock_item_id').addEventListener('change', refreshVisibility);
enabled.addEventListener('change', refreshVisibility);
method.addEventListener('change', refreshVisibility);
refreshVisibility();
Expand Down
31 changes: 27 additions & 4 deletions app/templates/timer/manual_entry.html
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,25 @@ <h2 class="text-sm font-semibold text-text-light dark:text-text-dark uppercase t
else updateWorkedFromStartEnd();
}

// Defer recalculation so the DOM has the latest date/time values (Issue #559)
let scheduledWorkedTime = false;
let pendingStart = false;
let pendingEnd = false;
function scheduleWorkedTimeUpdate(isStart) {
if (isStart) pendingStart = true; else pendingEnd = true;
if (scheduledWorkedTime) return;
scheduledWorkedTime = true;
queueMicrotask(function() {
scheduledWorkedTime = false;
const runStart = pendingStart;
const runEnd = pendingEnd;
pendingStart = false;
pendingEnd = false;
if (runStart) onStartChange();
if (runEnd) updateWorkedFromStartEnd();
});
}

// Dynamic task loading when a project is chosen
const projectSelect = document.getElementById('project_id');
const clientSelect = document.getElementById('client_id');
Expand Down Expand Up @@ -466,19 +485,23 @@ <h2 class="text-sm font-semibold text-text-light dark:text-text-dark uppercase t
});
}
if (startDate) {
startDate.addEventListener('change', () => { timeFieldsUserEdited = true; onStartChange(); });
startDate.addEventListener('change', () => { timeFieldsUserEdited = true; scheduleWorkedTimeUpdate(true); });
startDate.addEventListener('input', () => { timeFieldsUserEdited = true; scheduleWorkedTimeUpdate(true); });
startDate.addEventListener('focus', () => { if (startDate.disabled) { setDurationOnlyMode(false); ensureStartEndDefaultsIfEmpty(); } });
}
if (startTime) {
startTime.addEventListener('change', () => { timeFieldsUserEdited = true; onStartChange(); });
startTime.addEventListener('change', () => { timeFieldsUserEdited = true; scheduleWorkedTimeUpdate(true); });
startTime.addEventListener('input', () => { timeFieldsUserEdited = true; scheduleWorkedTimeUpdate(true); });
startTime.addEventListener('focus', () => { if (startTime.disabled) { setDurationOnlyMode(false); ensureStartEndDefaultsIfEmpty(); } });
}
if (endDate) {
endDate.addEventListener('change', () => { timeFieldsUserEdited = true; updateWorkedFromStartEnd(); });
endDate.addEventListener('change', () => { timeFieldsUserEdited = true; scheduleWorkedTimeUpdate(false); });
endDate.addEventListener('input', () => { timeFieldsUserEdited = true; scheduleWorkedTimeUpdate(false); });
endDate.addEventListener('focus', () => { if (endDate.disabled) { setDurationOnlyMode(false); ensureStartEndDefaultsIfEmpty(); } });
}
if (endTime) {
endTime.addEventListener('change', () => { timeFieldsUserEdited = true; updateWorkedFromStartEnd(); });
endTime.addEventListener('change', () => { timeFieldsUserEdited = true; scheduleWorkedTimeUpdate(false); });
endTime.addEventListener('input', () => { timeFieldsUserEdited = true; scheduleWorkedTimeUpdate(false); });
endTime.addEventListener('focus', () => { if (endTime.disabled) { setDurationOnlyMode(false); ensureStartEndDefaultsIfEmpty(); } });
}

Expand Down
1 change: 1 addition & 0 deletions docs/BREAK_TIME_FEATURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ This feature lets you account for break time when tracking work: either by pausi
### Break field

- On **Log Time** (manual entry) and **Edit time entry** you can enter **Break** in HH:MM (e.g. `0:30` for 30 minutes).
- **Worked time** is recalculated automatically whenever you change start/end date or time (including when using past dates); see [Manual Entry Worked Time Fix (#559)](implementation-notes/MANUAL_ENTRY_WORKED_TIME_FIX_559.md).
- **Effective duration** (what is stored and shown) = (end − start) − break. If you also use **Worked time**, that value is treated as net (after break); break can still be entered and is subtracted when both are present.
- Break is optional; leave it empty for no break.

Expand Down
Loading
Loading