diff --git a/.gitignore b/.gitignore index b50d332..13e0041 100644 --- a/.gitignore +++ b/.gitignore @@ -53,3 +53,6 @@ jspm_packages/ # Aider AI Chat .aider* + +# HAR captures (may contain Bearer tokens / PII) +*.har diff --git a/CLAUDE.md b/CLAUDE.md index 9bead4e..1a3b739 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,7 +110,7 @@ frappe.ui.form.on('My DocType', { ```python import frappe import requests -from invoice_az.auth import record_etaxes_activity, get_default_asan_login +from invoice_az.auth import record_etaxes_activity, get_default_asan_login, DEFAULT_HEADERS @frappe.whitelist() def my_etaxes_function(doc_name): @@ -134,7 +134,7 @@ def my_etaxes_function(doc_name): asan_login = get_default_asan_login() if not asan_login.get("found") or not asan_login.get("main_token"): return { - "success": False, + "error": "unauthorized", "message": "E-Taxes authentication not found. Please login via ASAN." } @@ -147,22 +147,29 @@ def my_etaxes_function(doc_name): # Step 6: Make API request response = requests.post( "https://new.e-taxes.gov.az/api/...", - data=json.dumps(payload), + json=payload, headers=headers, timeout=60 ) - # Step 7: Handle common errors + # Step 7: 401 — RETRY ONCE with a fresh token from DB, then fail if response.status_code == 401: - return { - "success": False, - "message": "Authentication expired. Please login again." - } + fresh_login = get_default_asan_login() + if fresh_login.get("found") and fresh_login.get("main_token"): + headers["x-authorization"] = f"Bearer {fresh_login['main_token']}" + response = requests.post(url, json=payload, headers=headers, timeout=60) + if response.status_code == 401: + return { + "error": "unauthorized", + "message": "Authentication required. Please login again." + } + # Step 8: Other error codes if response.status_code == 500: return { - "success": False, - "message": "E-Taxes service error. Please try again later." + "error": "server_error", + "message": "Service temporarily unavailable. Please try again in a few minutes.", + "status_code": 500 } if response.status_code == 404: @@ -173,43 +180,40 @@ def my_etaxes_function(doc_name): response.raise_for_status() - # Step 8: Process response + # Step 9: Process response result = response.json() - # Step 9: Return success + # Step 10: Persist any state changes explicitly + frappe.db.commit() + + # Step 11: Return success return { "success": True, "data": result } except requests.exceptions.RequestException as e: - frappe.log_error( - f"Network error: {str(e)}", - "E-Taxes API Error" - ) - return { - "success": False, - "message": f"Network error: {str(e)}" - } + frappe.log_error(f"Network error: {e}", "My Feature Error") + return {"success": False, "message": f"Network error: {e}"} except Exception as e: frappe.log_error( - f"Unexpected error: {str(e)}\n{frappe.get_traceback()}", - "E-Taxes API Error" + f"Unexpected error: {e}\n{frappe.get_traceback()}", + "My Feature Error" ) - return { - "success": False, - "message": f"Error: {str(e)}" - } + return {"success": False, "message": f"Error: {e}"} ``` **Key points:** -1. `@frappe.whitelist()` - ALWAYS required for frontend calls -2. `record_etaxes_activity()` - ALWAYS first (enables token auto-renewal) -3. `get_default_asan_login()` - Get token -4. `headers["x-authorization"] = f"Bearer {token}"` - Required for auth -5. Handle 401, 500, 404 errors explicitly -6. ALWAYS return `{"success": bool, "message": str}` format -7. ALWAYS use try-except with proper error logging +1. `@frappe.whitelist()` — always bare, no `methods=[...]`, no `allow_guest` +2. `record_etaxes_activity()` — ALWAYS first (resets inactivity timer, prevents token expiry mid-call) +3. `get_default_asan_login()` — read token from DB (scheduler may have refreshed it) +4. `headers["x-authorization"] = f"Bearer {token}"` — required for every call +5. **401 MUST retry once** with a fresh token before giving up — see §10 +6. Return codes by use case — see §11 +7. `frappe.db.commit()` after any `frappe.db.set_value` / `.insert()` / `.save()` / `.delete_doc()` — see §12 +8. Use `ignore_permissions=True` when inserting E-Taxes internal/tracking records +9. `frappe.log_error(msg, "Title Case Error")` — see §13 for title convention +10. Always try-except around the whole body ### 4. Constants and URLs @@ -350,6 +354,198 @@ bench --site site1 clear-cache **When in doubt:** Look at `send_sales_api.py` + `sales_invoice.js` - they contain all correct patterns! +### 10. 401 Auto-Refresh Pattern (MANDATORY) + +**A bare 401 must never be shown to the user as "Authentication expired".** The scheduler renews the token every 4 minutes in the background, so an in-flight request that hits 401 is frequently fixable by re-reading the token from the DB and retrying once. Only the *second* 401 in a row means genuine re-auth is required. + +**Reference implementation:** `invoice_az/sales_api.py:98-118`, `invoice_az/vat_api.py:~120-150`. + +```python +response = requests.get(url, headers=headers, timeout=60) + +# 401 → refresh token from DB and retry ONCE +if response.status_code == 401: + fresh_login = get_default_asan_login() + if fresh_login.get("found") and fresh_login.get("main_token"): + headers["x-authorization"] = f"Bearer {fresh_login['main_token']}" + response = requests.get(url, headers=headers, timeout=60) + if response.status_code == 401: + return { + "error": "unauthorized", + "message": "Authentication required. Please login again." + } +``` + +**Rules:** +- Retry **exactly once** — never loop, never escalate to `renew_token()` from inside a request handler. +- On the second 401, return the `error: "unauthorized"` shape so the frontend can trigger re-auth (see §11 and §14). +- Never return `{"success": False, "message": "Authentication expired..."}` — the frontend has no way to recognize that as an auth failure vs a business error. + +### 11. Response Shapes (TWO canonical formats) + +The app uses **two** response shapes — pick one per function based on use case. Never mix `success` and `error` in the same response. + +**Format A — `{success, message}`** (user-triggered operations: send invoice, sign act, fetch supplier data): +```python +return {"success": True, "message": "Act signed", "document_id": "..."} +return {"success": False, "message": "Item 3 has no product_group_code"} +``` + +**Format B — `{error, message, status_code?}`** (data-fetch / lower-level calls where the frontend may want to branch on the error code): +```python +return {"error": "unauthorized", "message": "Authentication required. Please login again."} +return {"error": "server_error", "message": "Service temporarily unavailable.", "status_code": 500} +return {"error": "http_error", "message": f"HTTP error {code}: {msg}"} +return {"error": "connection_error", "message": "..."} +``` + +**When to use which:** +- Format A: the frontend only needs "did it work, what to say to the user". +- Format B: the frontend needs to **branch on the failure mode** (e.g. trigger re-auth on `unauthorized`, show a retry button on `server_error`). Data-loading functions (load objects, load invoices, fetch reference data) should use Format B. + +**You MAY combine on "already authenticated check":** if `get_default_asan_login()` returns no token at all, return `{"error": "unauthorized", ...}` directly — the frontend reacts identically to a fresh 401. + +### 12. `frappe.db.commit()` Standard + +Frappe auto-commits at request end, but the E-Taxes code always commits **explicitly** after any state change so that: +- tokens persist immediately (another worker may read them before the request ends), +- partial progress in long-running loops isn't lost if the request later errors. + +**Commit after:** +- `frappe.db.set_value("Asan Login", ..., "last_activity_time", ...)` — see `auth.py:46, 57, 157, 173` +- `doc.insert(ignore_permissions=True)` for E-Taxes tracking records +- Any bulk loop that processes >1 record (commit after the loop, not after each iteration) +- `frappe.delete_doc(..., force=True)` cleanups + +**Do NOT commit after:** `frappe.get_doc()` reads, pure validations, dry-runs. + +### 13. Logging Title Convention + +`frappe.log_error(message, title)` — the **title** is `Title Case Error` (or `Info`, `Success`, `Debug`): + +| Good | Bad | +|------|-----| +| `"Token Renewal Error"` | `"token_renewal_error"` | +| `"Sales Invoice Validation Error"` | `"sales_invoice_validation"` | +| `"E-Taxes Object Load"` | `"object_load"` | +| `"Sales Order Tax Info"` | `"info"` | +| `"Signing Error"` | `"error signing"` | + +Group related entries under the same title so the Error Log is grouped usefully. Put the full traceback in the **message**, not the title. + +### 14. Frontend Auth Handling (MANDATORY pair with §10) + +When a backend call returns Format B `{error: "unauthorized"}` the frontend must detect it and offer re-auth. Pure `{success: false}` branches MUST NOT be used for auth — the frontend has no way to distinguish. + +**Canonical frontend handler** — see `client/e_taxes_customers_list.js:752-765`: + +```javascript +frappe.call({ + method: 'invoice_az.company_api.load_company_objects', + args: { company: frm.doc.name }, + callback: function(r) { + const msg = r.message || {}; + + if (msg.error === 'unauthorized') { + frappe.confirm( + __('Your E-Taxes session has expired. Would you like to authenticate now?'), + function() { + // Retry the same action after re-auth + if (typeof ETaxes !== 'undefined' && ETaxes.auth) { + ETaxes.auth.checkAndProcess(function() { load_objects(frm); }); + } else { + frappe.msgprint(__('Please authenticate via Asan Login, then try again.')); + } + } + ); + return; + } + + if (msg.error) { + frappe.msgprint({ title: __('Error'), message: msg.message, indicator: 'red' }); + return; + } + + if (msg.success) { + frappe.show_alert({ message: msg.message, indicator: 'green' }, 7); + } + } +}); +``` + +### 15. Field Naming & DocType Structure + +**E-Taxes reference doctypes (cached from API):** primary field uses the `etaxes__name` pattern: +- `E-Taxes Item` → `etaxes_item_name` (unique, primary, autoname: `field:etaxes_item_name`) +- `E-Taxes Unit` → `etaxes_unit_name` +- `E-Taxes Suppliers` → `etaxes_party_name` +- `E-Taxes Customers` → `etaxes_party_name` + +**Fixture-style doctypes (static/reference lookup data, not tied to invoices):** plain names — NO `etaxes_` prefix: +- `E-Taxes Item Group` → `code`, `name_az` +- `Classification code` → `classification_name` +- `E-Taxes Object` → `object_code`, `object_name` (taxpayer-owned, fixture-shaped) + +Rule of thumb: if the entity comes from an **invoice** and needs mapping back to an ERP doctype, use the `etaxes_` prefix. If it's a standalone reference/dictionary, use plain names. + +**Standard field order:** +1. Primary identifier (`unique=1`, `reqd=1`, `read_only=1`, `in_list_view=1`) +2. Secondary identifier (code / tax_id) +3. Status (`Select`, `in_list_view=1`, `in_standard_filter=1`) +4. Source tracking (optional `source_invoice`) +5. `Section Break` — grouped attribute sections (Activity, Classification, Address...) +6. `Section Break` — mapping/link section at the end (link to ERP entity or Company) + +**Standard permissions:** +- System Manager: full CRUD + share/export/print/report/email +- Accounts User: read-only + export/print/report/email/share (no create/write/delete) + +### 16. Background Jobs with Socket.IO (for bulk imports) + +Since the latest commit, bulk import operations use `frappe.enqueue` + `frappe.publish_realtime` instead of blocking requests. **Use this for any loop that processes more than ~50 records or calls E-Taxes more than ~10 times.** For small synchronous fetches (object list, supplier profile, serial number) stay synchronous. + +**Reference implementation:** `invoice_az/sales_api.py:883-979`. + +```python +# Entry point (whitelisted) +@frappe.whitelist() +def import_bulk_foo(ids): + frappe.enqueue( + _process_bulk_foo, + ids=ids, + user=frappe.session.user, + queue="default", + timeout=1200, + ) + return {"success": True, "enqueued": True, "total": len(ids)} + +# Worker (not whitelisted, underscore-prefixed) +def _process_bulk_foo(ids, user): + frappe.set_user(user) + record_etaxes_activity() + + total = len(ids) + for idx, id_ in enumerate(ids): + try: + process_one(id_) + except Exception as e: + frappe.log_error(f"Item {id_}: {e}", "Bulk Foo Error") + + frappe.publish_realtime( + "bulk_foo_progress", + {"current": idx + 1, "total": total}, + user=user, + ) + + frappe.publish_realtime( + "bulk_foo_complete", + {"total": total}, + user=user, + ) +``` + +**Event naming convention:** `{feature}_progress` (periodic), `{feature}_complete` (final). Frontend subscribes via `frappe.realtime.on(event, handler)`. + ## Development Commands ### Linting and Code Quality diff --git a/invoice_az/client/company.js b/invoice_az/client/company.js new file mode 100644 index 0000000..95a141c --- /dev/null +++ b/invoice_az/client/company.js @@ -0,0 +1,960 @@ +// ======= ОБЩИЕ УТИЛИТЫ И КОНСТАНТЫ ======= +const ETaxes = { + // Константы + PROGRESS_UPDATE_DELAY: 50, + AUTH_POLL_INTERVAL: 6000, + AUTH_MAX_ATTEMPTS: 20, + + // Глобальные переменные + loadingDialog: null, + cancelLoading: false, + loadingErrors: [], + + // Кэш + cache: { + defaultLogin: null, + cacheTime: null, + cacheDuration: 300000 // 5 минут + } +}; + +// ======= БАЗОВЫЕ УТИЛИТЫ ======= +ETaxes.utils = { + // Кэшированное получение настроек входа + getDefaultLogin: function(callback, useCache = true) { + const now = Date.now(); + + if (useCache && ETaxes.cache.defaultLogin && ETaxes.cache.cacheTime && + (now - ETaxes.cache.cacheTime) < ETaxes.cache.cacheDuration) { + callback(ETaxes.cache.defaultLogin); + return; + } + + frappe.call({ + method: 'invoice_az.auth.get_default_asan_login', + callback: function(r) { + if (r.message) { + ETaxes.cache.defaultLogin = r.message; + ETaxes.cache.cacheTime = now; + } + callback(r.message); + }, + error: function() { + callback({found: false, error: 'Network error'}); + } + }); + }, + + // Проверка валидности токена + checkTokenValidity: function(callback) { + frappe.call({ + method: 'invoice_az.auth.check_token_validity', + callback: function(r) { + callback(r.message && r.message.valid); + }, + error: function() { + callback(false); + } + }); + }, + + // Форматирование валюты + formatCurrency: function(amount) { + return new Intl.NumberFormat('az-AZ', { + style: 'currency', + currency: 'AZN', + minimumFractionDigits: 2 + }).format(amount || 0); + }, + + // Очистка кэша + clearCache: function() { + ETaxes.cache.defaultLogin = null; + ETaxes.cache.cacheTime = null; + } +}; + +// ======= ДИАЛОГИ ЗАГРУЗКИ ======= +ETaxes.dialogs = { + // Показать диалог загрузки + showLoading: function(title, message, submessage) { + if (ETaxes.loadingDialog) { + this.updateLoading(title, message, submessage); + return; + } + + ETaxes.loadingDialog = new frappe.ui.Dialog({ + title: title, + fields: [{ + fieldname: 'loading_html', + fieldtype: 'HTML', + options: this._getLoadingHTML(title, message, submessage) + }], + primary_action_label: __('Cancel'), + primary_action: function() { + ETaxes.cancelLoading = true; + ETaxes.loadingDialog.$wrapper.find('.primary-action').prop('disabled', true); + ETaxes.loadingDialog.$wrapper.find('.primary-action').html(__('Cancelling...')); + $('#status_message p').text(__('Cancelling the operation.')); + } + }); + + ETaxes.cancelLoading = false; + ETaxes.loadingDialog.show(); + ETaxes.loadingDialog.$wrapper.find('.modal-dialog').css('max-width', '450px'); + }, + + // Обновить сообщение в диалоге + updateLoading: function(title, message, submessage) { + if (!ETaxes.loadingDialog) return; + + if (title) $('#loading_title').text(title); + if (message) $('#loading_message').text(message); + if (submessage !== undefined) { + $('#loading_submessage').text(submessage); + $('#loading_submessage').toggle(!!submessage); + } + }, + + // Показать код верификации + showVerificationCode: function(code) { + if (ETaxes.loadingDialog && code) { + $('#verification_code').text(code); + $('#verification_code_container').show(); + } + }, + + // Установить статус успеха + setSuccess: function(message, submessage, callback, delay = 2000) { + if (!ETaxes.loadingDialog) { + if (callback) callback(); + return; + } + + ETaxes.loadingDialog.$wrapper.find('.loading-animation').html(` +
+ +
+ `); + + $('#loading_message').text(message); + if (submessage !== undefined) { + $('#loading_submessage').text(submessage); + $('#loading_submessage').toggle(!!submessage); + } + + $('#verification_code_container').hide(); + ETaxes.loadingDialog.set_primary_action(null); + ETaxes.loadingDialog.set_secondary_action(null); + + setTimeout(() => { + this.hide(); + if (callback) callback(); + }, delay); + }, + + // Установить статус ошибки + setError: function(message, submessage, showCloseButton = true) { + if (!ETaxes.loadingDialog) return; + + ETaxes.loadingDialog.set_title(__('Error')); + + ETaxes.loadingDialog.$wrapper.find('.loading-animation').html(` +
+ +
+ `); + + ETaxes.loadingDialog.$wrapper.find('#loading_title').text(''); + ETaxes.loadingDialog.$wrapper.find('#loading_submessage').remove(); + + const $msgElement = ETaxes.loadingDialog.$wrapper.find('#loading_message'); + if ($msgElement.length) { + $msgElement.css({ + 'color': '#d9534f', + 'font-weight': '500', + 'font-size': '14px', + 'margin-top': '15px' + }); + + if (submessage) { + $msgElement.html(submessage.replace(/\n/g, '
')); + } else { + $msgElement.text(message); + } + } + + $('#verification_code_container').hide(); + + if (showCloseButton) { + ETaxes.loadingDialog.set_primary_action(__('Close'), () => this.hide()); + } + }, + + // Скрыть диалог + hide: function() { + if (ETaxes.loadingDialog) { + try { + ETaxes.loadingDialog.hide(); + } catch (e) { + console.error("Error hiding loading dialog:", e); + } + ETaxes.loadingDialog = null; + } + ETaxes.cancelLoading = false; + }, + + // Получить HTML для диалога загрузки + _getLoadingHTML: function(title, message, submessage) { + return ` +
+
+
+
+ +
+

${title || __('Please wait...')}

+

${message || __('The operation may take some time')}

+

${submessage || ''}

+
+ +
+ `; + } +}; + +// ======= МОДУЛЬ АУТЕНТИФИКАЦИИ ======= +ETaxes.auth = { + checkAndProcess: function(callback) { + ETaxes.utils.checkTokenValidity(function(isValid) { + if (isValid) { + callback(); + } else { + frappe.confirm( + __('Your E-Taxes session has expired or authentication is required. Would you like to authenticate now?'), + function() { + ETaxes.auth.startProcess(callback); + }, + function() { + frappe.show_alert({ + message: __('Authentication canceled. Operation cannot be completed.'), + indicator: 'red' + }, 5); + } + ); + } + }); + }, + + startProcess: function(callback) { + ETaxes.dialogs.showLoading( + __('Starting Authentication'), + __('Getting Asan Login settings...'), + __('Please wait') + ); + + ETaxes.utils.getDefaultLogin(function(loginData) { + if (loginData && loginData.found) { + const asanLoginName = loginData.name; + ETaxes.dialogs.updateLoading( + null, + __('Sending authentication request...'), + __('This will send a request to your Asan Imza mobile app') + ); + + frappe.call({ + method: 'invoice_az.auth.handle_authentication', + args: { 'asan_login_name': asanLoginName }, + callback: function(r) { + if (r.message && r.message.success) { + const bearerToken = r.message.bearer_token; + + if (r.message.verification_code) { + ETaxes.dialogs.showVerificationCode(r.message.verification_code); + } + + ETaxes.dialogs.updateLoading( + null, + __('Waiting for confirmation on your phone'), + __('Please check your phone and confirm the authentication request') + ); + + ETaxes.auth.pollStatus(asanLoginName, bearerToken, function(success) { + if (success) { + ETaxes.auth.complete(asanLoginName, callback); + } else { + ETaxes.dialogs.setError( + __('Authentication Failed'), + __('Could not authenticate with Asan Imza') + ); + } + }); + } else { + ETaxes.dialogs.setError( + __('Authentication Error'), + r.message ? r.message.message : __('Authentication request failed') + ); + } + } + }); + } else { + ETaxes.dialogs.setError( + __('No Asan Login Settings'), + __('Please configure Asan Login settings first') + ); + } + }, false); + }, + + pollStatus: function(asanLoginName, bearerToken, callback) { + let attempts = 0; + let stopPolling = false; + + function pollStatusStep() { + if (attempts >= ETaxes.AUTH_MAX_ATTEMPTS || stopPolling) { + if (attempts >= ETaxes.AUTH_MAX_ATTEMPTS) { + ETaxes.dialogs.setError( + __('Authentication Timeout'), + __('The authentication request has timed out. Please try again.') + ); + callback(false); + } + return; + } + + attempts++; + + ETaxes.dialogs.updateLoading( + null, + __('Waiting for confirmation on your phone'), + __('Please check your phone and confirm the authentication request') + + ' (' + attempts + '/' + ETaxes.AUTH_MAX_ATTEMPTS + ')' + ); + + frappe.call({ + method: 'invoice_az.auth.poll_auth_status', + args: { + 'asan_login_name': asanLoginName, + 'bearer_token': bearerToken + }, + callback: function(r) { + if (r.message && r.message.success) { + if (r.message.authenticated) { + stopPolling = true; + ETaxes.dialogs.setSuccess( + __('Authentication Successful'), + __('You are now authenticated with Asan Imza'), + function() { + callback(true); + } + ); + } else { + setTimeout(pollStatusStep, ETaxes.AUTH_POLL_INTERVAL); + } + } else { + stopPolling = true; + ETaxes.dialogs.setError( + __('Authentication Error'), + r.message ? r.message.message : __('An unknown error occurred') + ); + callback(false); + } + }, + error: function(err) { + console.error('Error during status check:', err); + setTimeout(pollStatusStep, ETaxes.AUTH_POLL_INTERVAL); + } + }); + } + + pollStatusStep(); + }, + + complete: function(asanLoginName, callback) { + ETaxes.dialogs.showLoading( + __('Completing Authentication'), + __('Getting certificates...'), + __('Please wait') + ); + + frappe.call({ + method: 'invoice_az.auth.get_auth_certificates', + args: { 'asan_login_name': asanLoginName }, + callback: function(certR) { + if (certR.message && certR.message.success) { + ETaxes.dialogs.updateLoading( + null, + __('Certificates retrieved'), + __('Selecting certificate and taxpayer') + ); + + frappe.call({ + method: 'frappe.client.get', + args: { + doctype: 'Asan Login', + name: asanLoginName + }, + callback: function(r) { + if (r.message && r.message.selected_certificate_json) { + try { + const certData = JSON.parse(r.message.selected_certificate_json); + const certName = r.message.selected_certificate; + + ETaxes.dialogs.updateLoading( + null, + __('Selecting certificate'), + certName + ); + + ETaxes.auth._selectCertificate(asanLoginName, certData, certName, callback); + } catch (e) { + console.error('Error parsing certificate:', e); + ETaxes.dialogs.hide(); + ETaxes.auth._showCertificateSelector(certR.message.certificates, asanLoginName, callback); + } + } else { + ETaxes.dialogs.hide(); + ETaxes.auth._showCertificateSelector(certR.message.certificates, asanLoginName, callback); + } + } + }); + } else { + ETaxes.dialogs.setError( + __('Error'), + certR.message ? certR.message.message : __('Failed to get certificates') + ); + } + } + }); + }, + + _selectCertificate: function(asanLoginName, certData, certName, callback) { + frappe.call({ + method: 'invoice_az.auth.select_certificate', + args: { + 'asan_login_name': asanLoginName, + 'certificate_data': certData, + 'certificate_name': certName + }, + callback: function(r) { + if (r.message && r.message.success) { + ETaxes.dialogs.updateLoading( + null, + __('Selecting taxpayer'), + __('Using certificate: ') + certName + ); + + frappe.call({ + method: 'invoice_az.auth.select_taxpayer', + args: { 'asan_login_name': asanLoginName }, + callback: function(r) { + if (r.message && r.message.success) { + ETaxes.utils.clearCache(); + ETaxes.dialogs.setSuccess( + __('Authentication Complete'), + __('You can now access E-Taxes services'), + callback + ); + } else { + ETaxes.dialogs.setError( + __('Error'), + r.message ? r.message.message : __('Failed to select taxpayer') + ); + } + } + }); + } else { + ETaxes.dialogs.setError( + __('Error'), + r.message ? r.message.message : __('Failed to select certificate') + ); + } + } + }); + }, + + _showCertificateSelector: function(certificates, asanLoginName, callback) { + if (!certificates || !certificates.length) { + frappe.msgprint(__('No certificates available')); + return; + } + + let certHtml = '
'; + certHtml += ''; + + certificates.forEach(function(cert, index) { + let name = ''; + let id = ''; + + if (cert.taxpayerType === 'individual' && cert.individualInfo) { + name = cert.individualInfo.name || ''; + id = cert.individualInfo.fin || ''; + } else if (cert.legalInfo) { + name = cert.legalInfo.name || ''; + id = cert.legalInfo.tin || cert.legalInfo.voen || ''; + } + + certHtml += '' + + '' + + '' + + '' + + '' + + '' + + '' + + ''; + }); + + certHtml += '
TypeNameIDPositionStatus
' + (cert.taxpayerType || '') + '' + name + '' + id + '' + (cert.position || '') + '' + (cert.hasAccess ? 'Active' : 'Inactive') + (cert.liquidated ? ' (Liquidated)' : '') + '
'; + + const dialog = new frappe.ui.Dialog({ + title: __('Select Certificate'), + fields: [{ + fieldtype: 'HTML', + fieldname: 'certificates', + options: certHtml + }] + }); + + dialog.show(); + + dialog.$wrapper.find('.select-cert').on('click', function() { + const index = $(this).data('index'); + const cert = certificates[index]; + + let certName = ''; + if (cert.taxpayerType === 'individual' && cert.individualInfo) { + certName = `${cert.individualInfo.name} (${cert.individualInfo.fin})`; + } else if (cert.legalInfo) { + certName = `${cert.legalInfo.name} (${cert.legalInfo.voen || cert.legalInfo.tin})`; + } else { + certName = `Certificate ${index + 1}`; + } + + dialog.hide(); + + ETaxes.dialogs.showLoading( + __('Selecting Certificate'), + __('Processing your selection...'), + certName + ); + + ETaxes.auth._selectCertificate(asanLoginName, cert, certName, callback); + }); + } +}; +// ======= END ETaxes MODULE ======= + + +// ======= COMPANY FORM: DATA LOADING ======= +frappe.ui.form.on('Company', { + refresh: function(frm) { + if (frm.is_new()) return; + frm.add_custom_button(__('Data Loading'), function() { + show_data_loading_dialog(frm); + }, __('E-Taxes')); + render_all_reference_sections(frm); + } +}); + +// ======= REFERENCE LIST SECTIONS (Tax Policy tab) ======= + +const REFERENCE_SECTIONS = [ + { + doctype: 'E-Taxes Object', + html_fieldname: 'objects_list_html', + list_route: '/app/e-taxes-object', + columns: [ + { label: 'Object Code', field: 'object_code', link_to_item: true }, + { label: 'Name', field: 'object_name' }, + { label: 'Status', field: 'object_status', + indicator: { active: 'green', liquidated: 'red' } } + ] + }, + { + doctype: 'E-Taxes Cash Register', + html_fieldname: 'cash_registers_list_html', + list_route: '/app/e-taxes-cash-register', + columns: [ + { label: 'Serial Number', field: 'number', link_to_item: true }, + { label: 'Model', field: 'model' }, + { label: 'E-Cash Status', field: 'ecash_status', + indicator: { ACTIVE: 'green', ELIMINATED: 'red' } }, + { label: 'Object', field: 'object', + link_to: (v) => '/app/e-taxes-object/' + encodeURIComponent(v) }, + { label: 'Installed At', field: 'installed_at', format: 'date' } + ] + }, + { + doctype: 'E-Taxes POS Terminal', + html_fieldname: 'pos_terminals_list_html', + list_route: '/app/e-taxes-pos-terminal', + columns: [ + { label: 'Registration Number', field: 'registration_number', link_to_item: true }, + { label: 'Serial Number', field: 'serial_number' }, + { label: 'Status', field: 'status', + indicator: { A: 'green', C: 'red' } }, + { label: 'Registration Date', field: 'registration_date', format: 'date' } + ] + }, + { + doctype: 'E-Taxes Bank Account', + html_fieldname: 'bank_accounts_list_html', + list_route: '/app/e-taxes-bank-account', + columns: [ + { label: 'Account Number', field: 'number', link_to_item: true }, + { label: 'Currency', field: 'currency' }, + { label: 'Status', field: 'status', + indicator: { A: 'green', C: 'red' } }, + { label: 'Bank Name', field: 'bank_name' } + ] + }, + { + doctype: 'E-Taxes Obligation Pact', + html_fieldname: 'obligation_pacts_list_html', + list_route: '/app/e-taxes-obligation-pact', + columns: [ + { label: 'Code', field: 'code', link_to_item: true }, + { label: 'Name', field: 'name_az' }, + { label: 'Validity', field: 'validity_date', format: 'date' }, + { label: 'Expire', field: 'expire_date', format: 'date' } + ] + }, + { + doctype: 'E-Taxes Presented Certificate', + html_fieldname: 'presented_certificates_list_html', + list_route: '/app/e-taxes-presented-certificate', + columns: [ + { label: 'Cert No', field: 'cert_no', link_to_item: true }, + { label: 'Taxpayer', field: 'taxpayer_full_name' }, + { label: 'Category', field: 'cert_category', + indicator: { ACCEPTED: 'green', PRESENTED: 'blue', REJECTED: 'red', PENDING: 'orange' } } + ] + } +]; + +function render_all_reference_sections(frm) { + // Defer to next tick so HTML fields are mounted in DOM. + setTimeout(function() { + REFERENCE_SECTIONS.forEach(function(section) { + load_reference_section(frm, section); + }); + }, 100); +} + +function section_container(frm, section) { + const field = frm.get_field(section.html_fieldname); + return (field && field.$wrapper) ? field.$wrapper : null; +} + +function load_reference_section(frm, section) { + const $c = section_container(frm, section); + if (!$c) return; + + frappe.call({ + method: 'invoice_az.company_api.get_company_reference_list', + args: { doctype: section.doctype, company: frm.doc.name, limit: 50 }, + callback: function(r) { + if (r.message && r.message.success) { + render_reference_list(frm, section, r.message.data, r.message.total_count); + } else { + const error_msg = r.message ? r.message.message : 'Unknown error'; + $c.html(`
+
+
+

Error loading ${frappe.utils.escape_html(section.doctype)}: ${frappe.utils.escape_html(error_msg)}

+ +
+
+
`); + $c.off('click', '.retry-section-btn').on('click', '.retry-section-btn', function() { + load_reference_section(frm, section); + }); + } + }, + error: function(xhr, status, error) { + $c.html(`
+
+
+

Network error loading ${frappe.utils.escape_html(section.doctype)}: ${frappe.utils.escape_html(error || '')}

+ +
+
+
`); + $c.off('click', '.retry-section-btn').on('click', '.retry-section-btn', function() { + load_reference_section(frm, section); + }); + } + }); +} + +function render_reference_list(frm, section, rows, total_count) { + const $c = section_container(frm, section); + if (!$c) return; + + const esc = frappe.utils.escape_html; + + let html = `
+
+
+
${esc(section.doctype)} (${total_count} total)
+
+
+ +
+
+
`; + + if (rows && rows.length > 0) { + html += '
'; + html += '
'; + html += ''; + html += ''; + section.columns.forEach(function(col) { html += ''; }); + html += ''; + html += ''; + + rows.forEach(function(row) { + html += ''; + section.columns.forEach(function(col) { + const val = row[col.field]; + html += ''; + }); + html += ''; + }); + + html += '
' + esc(__(col.label)) + '
' + render_cell(row, col, val, section) + '
'; + html += '
'; // section-body + html += '
'; // form-section + + if (total_count > rows.length) { + html += `
+

Showing ${rows.length} of ${total_count}

+ View All ${esc(section.doctype)} +
`; + } + } else { + html += '
'; + html += '
'; + html += '
'; + html += '

No records found. Use "Data Loading" to import from E-Taxes.

'; + html += '
'; + html += '
'; + html += '
'; + } + + $c.html(html); + + $c.off('click', '.refresh-section-btn').on('click', '.refresh-section-btn', function() { + $c.html('
Loading...
'); + load_reference_section(frm, section); + }); +} + +function render_cell(row, col, val, section) { + const esc = frappe.utils.escape_html; + if (val == null || val === '') return '-'; + + if (col.indicator) { + const color = col.indicator[val] || 'gray'; + return '' + esc(String(val)) + ''; + } + if (col.format === 'date') { + return esc(frappe.datetime.str_to_user(val)); + } + if (col.link_to_item) { + return '' + + esc(String(val)) + ''; + } + if (typeof col.link_to === 'function') { + return '' + esc(String(val)) + ''; + } + return esc(String(val)); +} + +// Ordered list of data types loadable from the Company → Data Loading dialog. +// Adding a new loader = append an entry here. +const DATA_LOADERS = [ + { + key: 'load_objects', + label: __('Load Objects'), + description: __('Load company objects (taxpayer registered locations) from E-Taxes'), + default: 1, + method: 'invoice_az.company_api.load_company_objects', + freezeMessage: __('Loading objects from E-Taxes...') + }, + { + key: 'load_cash_registers', + label: __('Load Cash Registers'), + description: __('Load cash registers (kassa aparatları) from E-Taxes. Uses pagination.'), + default: 1, + method: 'invoice_az.company_api.load_company_cash_registers', + freezeMessage: __('Loading cash registers from E-Taxes...') + }, + { + key: 'load_pos_terminals', + label: __('Load POS Terminals'), + description: __('Load POS terminals from E-Taxes. Uses pagination.'), + default: 1, + method: 'invoice_az.company_api.load_company_pos_terminals', + freezeMessage: __('Loading POS terminals from E-Taxes...') + }, + { + key: 'load_bank_accounts', + label: __('Load Bank Accounts'), + description: __('Load bank accounts (bank hesabları) from E-Taxes'), + default: 1, + method: 'invoice_az.company_api.load_company_bank_accounts', + freezeMessage: __('Loading bank accounts from E-Taxes...') + }, + { + key: 'load_obligation_pacts', + label: __('Load Obligation Pacts (Sazişlər)'), + description: __('Load sub-contractor obligation pacts (oil fields / sazişlər) from E-Taxes'), + default: 1, + method: 'invoice_az.company_api.load_company_obligation_pacts', + freezeMessage: __('Loading obligation pacts from E-Taxes...') + }, + { + key: 'load_presented_certificates', + label: __('Load Presented Certificates'), + description: __('Load presented/accepted e-certificates from E-Taxes. Requires obligation pacts for links to resolve.'), + default: 1, + method: 'invoice_az.company_api.load_company_presented_certificates', + freezeMessage: __('Loading presented certificates from E-Taxes...') + } +]; + +function show_data_loading_dialog(frm) { + const fields = [{ + fieldname: 'data_types_section', + fieldtype: 'Section Break', + label: __('Data Types to Load') + }]; + + DATA_LOADERS.forEach(function(loader) { + fields.push({ + fieldname: loader.key, + fieldtype: 'Check', + label: loader.label, + default: loader.default, + description: loader.description + }); + }); + + const d = new frappe.ui.Dialog({ + title: __('Load Company Reference Data from E-Taxes'), + fields: fields, + size: 'small', + primary_action_label: __('Load'), + primary_action: function() { + const values = d.get_values(); + const selected = DATA_LOADERS.filter(function(l) { return values[l.key]; }); + if (!selected.length) { + frappe.msgprint(__('Please select at least one data type to load.')); + return; + } + d.hide(); + ETaxes.auth.checkAndProcess(function() { + run_data_loading(frm, selected); + }); + } + }); + d.show(); +} + +const PROGRESS_EVENT = 'company_data_loading_progress'; +const COMPLETE_EVENT = 'company_data_loading_complete'; + +function run_data_loading(frm, loaders) { + const keys = loaders.map(function(l) { return l.key; }); + + // Handlers are closed over per-run so we can detach cleanly at end + let progress_handler = null; + let complete_handler = null; + + const detach = function() { + if (progress_handler) frappe.realtime.off(PROGRESS_EVENT, progress_handler); + if (complete_handler) frappe.realtime.off(COMPLETE_EVENT, complete_handler); + progress_handler = null; + complete_handler = null; + }; + + progress_handler = function(data) { + if (!data || !data.total) return; + const pct = Math.round((data.current / data.total) * 100); + const desc = data.message || ''; + frappe.show_progress(__('Loading E-Taxes Data'), pct, 100, desc); + }; + + complete_handler = function(data) { + detach(); + frappe.hide_progress(); + + if (data && data.error === 'unauthorized') { + frappe.confirm( + __('Your E-Taxes session has expired. Would you like to authenticate now?'), + function() { + ETaxes.auth.checkAndProcess(function() { + run_data_loading(frm, loaders); + }); + } + ); + return; + } + + const lines = []; + (data.summary || []).forEach(function(item) { + const res = item.result || {}; + if (res.success) { + lines.push('' + item.label + ': ' + (res.message || __('OK'))); + } else { + lines.push('' + item.label + ': ' + + (res.message || __('Unknown error'))); + } + }); + + frappe.msgprint({ + title: __('Data Loading Complete'), + message: lines.join('
') || __('Nothing was loaded.'), + indicator: 'green' + }); + }; + + frappe.realtime.on(PROGRESS_EVENT, progress_handler); + frappe.realtime.on(COMPLETE_EVENT, complete_handler); + + frappe.show_progress(__('Loading E-Taxes Data'), 0, 100, __('Starting...')); + + frappe.call({ + method: 'invoice_az.company_api.load_company_data_bulk', + args: { company: frm.doc.name, selections: JSON.stringify(keys) }, + callback: function(r) { + const msg = r.message || {}; + if (!msg.enqueued) { + detach(); + frappe.hide_progress(); + frappe.msgprint({ + title: __('Failed to Start'), + message: msg.message || __('Could not enqueue the loading job.'), + indicator: 'red' + }); + } + }, + error: function() { + detach(); + frappe.hide_progress(); + } + }); +} diff --git a/invoice_az/company_api.py b/invoice_az/company_api.py new file mode 100644 index 0000000..5f2d133 --- /dev/null +++ b/invoice_az/company_api.py @@ -0,0 +1,762 @@ +# ======= COMPANY E-TAXES DATA LOADING ======= + +import frappe +import requests +from frappe.utils import getdate + +from invoice_az.auth import DEFAULT_HEADERS, get_default_asan_login, record_etaxes_activity + +BASE_URL = "https://new.e-taxes.gov.az" +OBJECT_LIST_URL = f"{BASE_URL}/api/po/profile/public/v1/object/list" +CASH_REGISTER_LIST_URL = f"{BASE_URL}/api/po/profile/public/v2/profile/cashRegister/list" +POS_TERMINAL_LIST_URL = f"{BASE_URL}/api/po/profile/public/v2/profile/posTerminal/list" +BANK_ACCOUNT_LIST_URL = f"{BASE_URL}/api/po/profile/public/v1/profile/bankAccount/list" +OBL_PACT_LIST_URL = f"{BASE_URL}/api/po/edi-legal/public/v1/application/sub-contractor/obl-pact-list" +PRESENTED_CERT_LIST_URL = f"{BASE_URL}/api/po/edi-legal/public/v1/application/contractor/presented-accepted-e-certificates" + +PAGE_SIZE = 200 +MAX_PAGES = 50 # safety cap (≤ 10 000 records per import) + + +# ======= HELPERS ======= + +def _get(d, *path): + cur = d + for key in path: + if not isinstance(cur, dict): + return None + cur = cur.get(key) + return cur + + +def _safe_date(value): + if not value: + return None + try: + return getdate(value) + except Exception: + return None + + +def _unauthorized(): + return { + "error": "unauthorized", + "message": "Authentication required. Please login again.", + } + + +def _server_error(): + return { + "error": "server_error", + "message": "Service temporarily unavailable. Please try again in a few minutes.", + "status_code": 500, + } + + +def _auth_headers(): + """Return (headers, error_response_or_None). If err is truthy, bail out.""" + asan_login = get_default_asan_login() + if not asan_login.get("found") or not asan_login.get("main_token"): + return None, _unauthorized() + headers = DEFAULT_HEADERS.copy() + headers["x-authorization"] = f"Bearer {asan_login['main_token']}" + return headers, None + + +def _etaxes_request(method, url, headers, **kwargs): + """Make an E-Taxes request with the §10 401-retry-once pattern. + + Returns (response, error_dict) — if error_dict is truthy, caller must return it. + """ + try: + response = requests.request(method, url, headers=headers, timeout=60, **kwargs) + + if response.status_code == 401: + fresh_login = get_default_asan_login() + if fresh_login.get("found") and fresh_login.get("main_token"): + headers["x-authorization"] = f"Bearer {fresh_login['main_token']}" + response = requests.request(method, url, headers=headers, timeout=60, **kwargs) + if response.status_code == 401: + return None, _unauthorized() + + if response.status_code == 500: + return None, _server_error() + + if response.status_code == 404: + return None, {"success": False, "message": "Resource not found."} + + response.raise_for_status() + return response, None + + except requests.exceptions.HTTPError as e: + code = e.response.status_code if e.response is not None else None + if code == 401: + return None, _unauthorized() + if code == 500: + return None, _server_error() + frappe.log_error(f"HTTP error {code}: {e}", "E-Taxes Company Load") + return None, {"error": "http_error", "message": f"HTTP error {code}: {e}"} + except requests.exceptions.RequestException as e: + frappe.log_error(f"Network error: {e}", "E-Taxes Company Load") + return None, {"error": "connection_error", "message": f"Network error: {e}"} + + +# ======= OBJECTS ======= + +@frappe.whitelist() +def load_company_objects(company): + """Load taxpayer objects (registered locations) from E-Taxes into E-Taxes Object doctype.""" + try: + record_etaxes_activity() + + if not company or not frappe.db.exists("Company", company): + return {"success": False, "message": f"Company '{company}' not found."} + + headers, err = _auth_headers() + if err: + return err + + response, err = _etaxes_request("GET", OBJECT_LIST_URL, headers) + if err: + return err + + data = response.json() or {} + objects = data.get("objectInfoShortList") or [] + + created, updated = 0, 0 + for obj in objects: + code = obj.get("objectCode") + if not code: + continue + + values = { + "object_name": (obj.get("objectName") or "").strip(), + "object_status": obj.get("status") or "active", + "address": obj.get("address"), + "main_activity_code": _get(obj, "mainActivity", "code"), + "main_activity_name": _get(obj, "mainActivity", "name", "az"), + "main_class_code": _get(obj, "mainClass", "code"), + "main_class_name": _get(obj, "mainClass", "name", "az"), + "middle_class_code": _get(obj, "middleClass", "code"), + "middle_class_name": _get(obj, "middleClass", "name", "az"), + "sub_class_code": _get(obj, "subClass", "code"), + "sub_class_name": _get(obj, "subClass", "name", "az"), + "company": company, + } + + if frappe.db.exists("E-Taxes Object", code): + frappe.db.set_value("E-Taxes Object", code, values, update_modified=False) + updated += 1 + else: + doc = frappe.get_doc({ + "doctype": "E-Taxes Object", + "object_code": code, + **values, + }) + doc.insert(ignore_permissions=True) + created += 1 + + frappe.db.commit() + + return { + "success": True, + "message": f"Objects loaded: {created} created, {updated} updated", + "created": created, + "updated": updated, + "total": len(objects), + } + + except Exception as e: + frappe.log_error( + f"Unexpected error: {e}\n{frappe.get_traceback()}", + "E-Taxes Object Load", + ) + return {"success": False, "message": f"Error: {e}"} + + +# ======= CASH REGISTERS ======= + +@frappe.whitelist() +def load_company_cash_registers(company): + """Load cash registers from E-Taxes into E-Taxes Cash Register doctype. + + Uses hasMore pagination (CLAUDE.md §10 401-retry-once applies per page). + """ + try: + record_etaxes_activity() + + if not company or not frappe.db.exists("Company", company): + return {"success": False, "message": f"Company '{company}' not found."} + + headers, err = _auth_headers() + if err: + return err + + created, updated, total_fetched = 0, 0, 0 + offset = 0 + + for page in range(MAX_PAGES): + payload = {"maxCount": PAGE_SIZE, "offset": offset} + response, err = _etaxes_request( + "POST", CASH_REGISTER_LIST_URL, headers, json=payload + ) + if err: + return err + + data = response.json() or {} + registers = data.get("cashRegisters") or [] + has_more = bool(data.get("hasMore")) + + if not registers: + break + + for reg in registers: + token = reg.get("token") + if not token: + continue + + values = { + "number": reg.get("number"), + "model": reg.get("model"), + "ecash_status": reg.get("ecashStatus"), + "status_code": reg.get("status"), + "object": reg.get("object") if frappe.db.exists("E-Taxes Object", reg.get("object")) else None, + "certificate_type": reg.get("certificateType"), + "installed_at": _safe_date(reg.get("installedAt")), + "certificate_from": _safe_date(reg.get("certificateFrom")), + "certificate_to": _safe_date(reg.get("certificateTo")), + "replacement_date": _safe_date(reg.get("replacementDate")), + "operator_tin": reg.get("operatorTin"), + "operator_name": reg.get("operatorName"), + "company": company, + } + + if frappe.db.exists("E-Taxes Cash Register", token): + frappe.db.set_value( + "E-Taxes Cash Register", token, values, update_modified=False + ) + updated += 1 + else: + doc = frappe.get_doc({ + "doctype": "E-Taxes Cash Register", + "token": token, + **values, + }) + doc.insert(ignore_permissions=True) + created += 1 + + total_fetched += len(registers) + offset += PAGE_SIZE + + if not has_more: + break + else: + frappe.log_error( + f"Cash register pagination hit safety cap of {MAX_PAGES} pages " + f"for company {company}", + "E-Taxes Cash Register Load", + ) + + frappe.db.commit() + + return { + "success": True, + "message": f"Cash registers loaded: {created} created, {updated} updated", + "created": created, + "updated": updated, + "total": total_fetched, + } + + except Exception as e: + frappe.log_error( + f"Unexpected error: {e}\n{frappe.get_traceback()}", + "E-Taxes Cash Register Load", + ) + return {"success": False, "message": f"Error: {e}"} + + +# ======= POS TERMINALS ======= + +def _object_from_registration_number(registration_number): + """Registration numbers look like "4800111741-84001P02" — strip the trailing Pxx.""" + if not registration_number: + return None + code = registration_number.rsplit("P", 1)[0] + if frappe.db.exists("E-Taxes Object", code): + return code + return None + + +@frappe.whitelist() +def load_company_pos_terminals(company): + """Load POS terminals from E-Taxes into E-Taxes POS Terminal doctype. + + Uses hasMore pagination (CLAUDE.md §10 401-retry-once applies per page). + """ + try: + record_etaxes_activity() + + if not company or not frappe.db.exists("Company", company): + return {"success": False, "message": f"Company '{company}' not found."} + + headers, err = _auth_headers() + if err: + return err + + created, updated, total_fetched = 0, 0, 0 + offset = 0 + + for page in range(MAX_PAGES): + payload = {"maxCount": PAGE_SIZE, "offset": offset} + response, err = _etaxes_request( + "POST", POS_TERMINAL_LIST_URL, headers, json=payload + ) + if err: + return err + + data = response.json() or {} + terminals = data.get("posTerminals") or [] + has_more = bool(data.get("hasMore")) + + if not terminals: + break + + for term in terminals: + registration_number = term.get("registrationNumber") + if not registration_number: + continue + + values = { + "serial_number": term.get("serialNumber"), + "status": term.get("status"), + "registration_date": _safe_date(term.get("registrationDate")), + "bank_name": term.get("bankName"), + "object": _object_from_registration_number(registration_number), + "company": company, + } + + if frappe.db.exists("E-Taxes POS Terminal", registration_number): + frappe.db.set_value( + "E-Taxes POS Terminal", registration_number, values, + update_modified=False, + ) + updated += 1 + else: + doc = frappe.get_doc({ + "doctype": "E-Taxes POS Terminal", + "registration_number": registration_number, + **values, + }) + doc.insert(ignore_permissions=True) + created += 1 + + total_fetched += len(terminals) + offset += PAGE_SIZE + + if not has_more: + break + else: + frappe.log_error( + f"POS terminal pagination hit safety cap of {MAX_PAGES} pages " + f"for company {company}", + "E-Taxes POS Terminal Load", + ) + + frappe.db.commit() + + return { + "success": True, + "message": f"POS terminals loaded: {created} created, {updated} updated", + "created": created, + "updated": updated, + "total": total_fetched, + } + + except Exception as e: + frappe.log_error( + f"Unexpected error: {e}\n{frappe.get_traceback()}", + "E-Taxes POS Terminal Load", + ) + return {"success": False, "message": f"Error: {e}"} + + +# ======= COMPACT DATE/DATETIME PARSERS ======= + +def _parse_compact_date(value): + """Parse "yyyyMMdd" → Date string "yyyy-MM-dd". Also accepts standard Date.""" + if not value: + return None + s = str(value).strip() + if len(s) == 8 and s.isdigit(): + s = f"{s[0:4]}-{s[4:6]}-{s[6:8]}" + return _safe_date(s) + + +def _parse_compact_datetime(value): + """Parse "yyyyMMddHHmmss" → Datetime string. Also accepts standard Datetime.""" + if not value: + return None + s = str(value).strip() + if len(s) == 14 and s.isdigit(): + return f"{s[0:4]}-{s[4:6]}-{s[6:8]} {s[8:10]}:{s[10:12]}:{s[12:14]}" + return s + + +def _upsert(doctype, primary_key_value, values, primary_field): + """Upsert by primary field. Returns (created, updated) deltas as (0,1) or (1,0).""" + if frappe.db.exists(doctype, primary_key_value): + frappe.db.set_value(doctype, primary_key_value, values, update_modified=False) + return 0, 1 + doc = frappe.get_doc({ + "doctype": doctype, + primary_field: primary_key_value, + **values, + }) + doc.insert(ignore_permissions=True) + return 1, 0 + + +def _summarize(created, updated, total, label): + return { + "success": True, + "message": f"{label}: {created} created, {updated} updated", + "created": created, + "updated": updated, + "total": total, + } + + +# ======= BANK ACCOUNTS ======= + +@frappe.whitelist() +def load_company_bank_accounts(company): + """Load bank accounts from E-Taxes into E-Taxes Bank Account doctype.""" + try: + record_etaxes_activity() + + if not company or not frappe.db.exists("Company", company): + return {"success": False, "message": f"Company '{company}' not found."} + + headers, err = _auth_headers() + if err: + return err + + response, err = _etaxes_request("GET", BANK_ACCOUNT_LIST_URL, headers) + if err: + return err + + data = response.json() or {} + accounts = data.get("bankAccounts") or [] + + created, updated = 0, 0 + for acc in accounts: + duplicate_number = acc.get("duplicateNumber") + if not duplicate_number: + continue + values = { + "number": acc.get("number"), + "currency": acc.get("currency"), + "status": acc.get("status"), + "account_type": acc.get("type"), + "registration_date": _safe_date(acc.get("registrationDate")), + "duplicate_date": _safe_date(acc.get("duplicateDate")), + "bank_name": acc.get("bankName"), + "bank_tin": acc.get("bankTin"), + "bank_code": acc.get("bankCode"), + "main_bank_code": acc.get("mainBankCode"), + "settlement_account": acc.get("settlementAccount"), + "company": company, + } + c, u = _upsert("E-Taxes Bank Account", duplicate_number, values, "duplicate_number") + created += c + updated += u + + frappe.db.commit() + return _summarize(created, updated, len(accounts), "Bank accounts loaded") + + except Exception as e: + frappe.log_error( + f"Unexpected error: {e}\n{frappe.get_traceback()}", + "E-Taxes Bank Account Load", + ) + return {"success": False, "message": f"Error: {e}"} + + +# ======= OBLIGATION PACTS (sub-contractor obl-pact-list) ======= + +@frappe.whitelist() +def load_company_obligation_pacts(company): + """Load obligation pacts (sazişlər — oil fields/agreements) from E-Taxes.""" + try: + record_etaxes_activity() + + if not company or not frappe.db.exists("Company", company): + return {"success": False, "message": f"Company '{company}' not found."} + + headers, err = _auth_headers() + if err: + return err + + response, err = _etaxes_request("GET", OBL_PACT_LIST_URL, headers) + if err: + return err + + data = response.json() or {} + pacts = data.get("oblPacts") or [] + + created, updated = 0, 0 + for item in pacts: + pact = item.get("oblPact") or {} + code = pact.get("code") + if not code: + continue + values = { + "name_az": _get(pact, "name", "az"), + "validity_date": _safe_date(item.get("validityDate")), + "expire_date": _safe_date(item.get("expireDate")), + "company": company, + } + c, u = _upsert("E-Taxes Obligation Pact", code, values, "code") + created += c + updated += u + + frappe.db.commit() + return _summarize(created, updated, len(pacts), "Obligation pacts loaded") + + except Exception as e: + frappe.log_error( + f"Unexpected error: {e}\n{frappe.get_traceback()}", + "E-Taxes Obligation Pact Load", + ) + return {"success": False, "message": f"Error: {e}"} + + +# ======= PRESENTED / ACCEPTED E-CERTIFICATES ======= + +def _map_certificate(cert, company): + sazis_code = cert.get("sazisKodu") + return { + "cert_no": cert.get("certNo"), + "cert_category": cert.get("certCategory"), + "cert_state": cert.get("certState"), + "operation_date": _parse_compact_datetime(cert.get("operationDate")), + "taxpayer_full_name": cert.get("taxPayerFullName"), + "voen": cert.get("voen"), + "sazis_name": cert.get("sazisName"), + "sazis_code": sazis_code if sazis_code and frappe.db.exists("E-Taxes Obligation Pact", sazis_code) else None, + "contract_start_date": _parse_compact_date(cert.get("contractStartDate")), + "contract_end_date": _parse_compact_date(cert.get("contractEndDate")), + "cert_start_date": _parse_compact_date(cert.get("certStartDate")), + "cert_end_date": _parse_compact_date(cert.get("certEndDate")), + "fk_doc_oid": cert.get("fkDocOid"), + "company": company, + } + + +@frappe.whitelist() +def load_company_presented_certificates(company): + """Load presented/accepted e-certificates from E-Taxes.""" + try: + record_etaxes_activity() + + if not company or not frappe.db.exists("Company", company): + return {"success": False, "message": f"Company '{company}' not found."} + + headers, err = _auth_headers() + if err: + return err + + response, err = _etaxes_request("GET", PRESENTED_CERT_LIST_URL, headers) + if err: + return err + + data = response.json() or {} + certs = data.get("certificateList") or [] + + created, updated = 0, 0 + for cert in certs: + oid = cert.get("oid") + if not oid: + continue + values = _map_certificate(cert, company) + c, u = _upsert("E-Taxes Presented Certificate", oid, values, "oid") + created += c + updated += u + + frappe.db.commit() + return _summarize(created, updated, len(certs), "Presented certificates loaded") + + except Exception as e: + frappe.log_error( + f"Unexpected error: {e}\n{frappe.get_traceback()}", + "E-Taxes Presented Certificate Load", + ) + return {"success": False, "message": f"Error: {e}"} + + + + +# ======= BULK ORCHESTRATOR (Socket.IO progress) ======= + +PROGRESS_EVENT = "company_data_loading_progress" +COMPLETE_EVENT = "company_data_loading_complete" + + +@frappe.whitelist() +def load_company_data_bulk(company, selections): + """Enqueue a background job that runs the selected loaders sequentially + and broadcasts progress/summary over Socket.IO. + + `selections` is either a JSON string or a list of loader keys: + ["load_objects", "load_cash_registers", ...] + """ + import json as _json + + if isinstance(selections, str): + try: + selections = _json.loads(selections) + except ValueError: + selections = [s.strip() for s in selections.split(",") if s.strip()] + if not isinstance(selections, list): + selections = [] + + if not company or not frappe.db.exists("Company", company): + return {"success": False, "message": f"Company '{company}' not found."} + if not selections: + return {"success": False, "message": "No loaders selected."} + + frappe.enqueue( + _process_company_data_bulk, + company=company, + selections=selections, + user=frappe.session.user, + queue="default", + timeout=1800, + ) + return {"success": True, "enqueued": True, "total": len(selections)} + + +def _process_company_data_bulk(company, selections, user): + """Background worker — runs each loader in turn, publishing realtime updates.""" + frappe.set_user(user) + record_etaxes_activity() + + loaders = { + "load_objects": (load_company_objects, "Objects"), + "load_cash_registers": (load_company_cash_registers, "Cash Registers"), + "load_pos_terminals": (load_company_pos_terminals, "POS Terminals"), + "load_bank_accounts": (load_company_bank_accounts, "Bank Accounts"), + "load_obligation_pacts": (load_company_obligation_pacts, "Obligation Pacts"), + "load_presented_certificates": (load_company_presented_certificates, "Presented Certificates"), + } + + total = len(selections) + summary = [] + + frappe.publish_realtime( + PROGRESS_EVENT, + {"current": 0, "total": total, "label": "", "message": "Starting..."}, + user=user, + ) + + for idx, key in enumerate(selections): + entry = loaders.get(key) + if not entry: + summary.append({"key": key, "label": key, "result": { + "success": False, "message": f"Unknown loader: {key}" + }}) + continue + loader_func, label = entry + + frappe.publish_realtime( + PROGRESS_EVENT, + {"current": idx, "total": total, "label": label, + "message": f"Loading {label}..."}, + user=user, + ) + + try: + result = loader_func(company) + except Exception as e: + frappe.log_error( + f"Bulk loader '{key}' crashed: {e}\n{frappe.get_traceback()}", + "Company Data Bulk Load", + ) + result = {"success": False, "message": f"{label}: {e}"} + + summary.append({"key": key, "label": label, "result": result}) + + # Abort early on auth failure — frontend will re-auth and retry + if isinstance(result, dict) and result.get("error") == "unauthorized": + frappe.publish_realtime( + COMPLETE_EVENT, + {"summary": summary, "total": total, "completed": idx, + "error": "unauthorized", + "message": "Authentication required. Please login again."}, + user=user, + ) + return + + frappe.publish_realtime( + PROGRESS_EVENT, + {"current": total, "total": total, "label": "", "message": "Done"}, + user=user, + ) + frappe.publish_realtime( + COMPLETE_EVENT, + {"summary": summary, "total": total, "completed": total}, + user=user, + ) + + +# ======= REFERENCE LIST (for Tax Policy tab sections) ======= + +_REFERENCE_LIST_FIELDS = { + "E-Taxes Object": ["name", "object_code", "object_name", "object_status"], + "E-Taxes Cash Register": ["name", "number", "model", "ecash_status", "object", "installed_at"], + "E-Taxes POS Terminal": ["name", "registration_number", "serial_number", "status", "registration_date"], + "E-Taxes Bank Account": ["name", "number", "currency", "status", "bank_name"], + "E-Taxes Obligation Pact": ["name", "code", "name_az", "validity_date", "expire_date"], + "E-Taxes Presented Certificate": ["name", "cert_no", "taxpayer_full_name", "cert_category"], +} + + +@frappe.whitelist() +def get_company_reference_list(doctype, company, limit=50): + """Return first `limit` records of an E-Taxes reference doctype, filtered by company. + + Only doctypes in the allow-list above are accessible via this endpoint. + """ + try: + if doctype not in _REFERENCE_LIST_FIELDS: + return {"success": False, "message": f"Doctype '{doctype}' not allowed."} + + if not company or not frappe.db.exists("Company", company): + return {"success": False, "message": f"Company '{company}' not found."} + + try: + limit = int(limit) + except (TypeError, ValueError): + limit = 50 + limit = max(1, min(limit, 200)) + + fields = _REFERENCE_LIST_FIELDS[doctype] + filters = {"company": company} + + data = frappe.get_all( + doctype, + filters=filters, + fields=fields, + limit=limit, + order_by="modified desc", + ) + total_count = frappe.db.count(doctype, filters=filters) + + return {"success": True, "data": data, "total_count": total_count} + + except Exception as e: + frappe.log_error( + f"Reference list load failed for {doctype}: {e}\n{frappe.get_traceback()}", + "Company Reference List", + ) + return {"success": False, "message": f"Error: {e}"} diff --git a/invoice_az/hooks.py b/invoice_az/hooks.py index 7e393e3..02ff546 100644 --- a/invoice_az/hooks.py +++ b/invoice_az/hooks.py @@ -13,7 +13,8 @@ doctype_js = { "Journal Entry": "client/journal_entry.js", "Supplier": "client/supplier.js", "Amas Employees": "client/amas_employees.js", - "Employee": "client/employee.js" + "Employee": "client/employee.js", + "Company": "client/company.js" } doctype_list_js = { diff --git a/invoice_az/invoice_az/doctype/e_taxes_bank_account/__init__.py b/invoice_az/invoice_az/doctype/e_taxes_bank_account/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/invoice_az/invoice_az/doctype/e_taxes_bank_account/e_taxes_bank_account.json b/invoice_az/invoice_az/doctype/e_taxes_bank_account/e_taxes_bank_account.json new file mode 100644 index 0000000..64e8b76 --- /dev/null +++ b/invoice_az/invoice_az/doctype/e_taxes_bank_account/e_taxes_bank_account.json @@ -0,0 +1,103 @@ +{ + "actions": [], + "autoname": "field:duplicate_number", + "creation": "2026-04-21 12:00:00", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "duplicate_number", + "number", + "currency", + "status", + "account_type", + "dates_section", + "registration_date", + "duplicate_date", + "bank_section", + "bank_name", + "bank_tin", + "bank_code", + "main_bank_code", + "settlement_account", + "company_section", + "company" + ], + "fields": [ + { + "fieldname": "duplicate_number", + "fieldtype": "Data", + "label": "Duplicate Number", + "length": 40, + "read_only": 1, + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "number", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Account Number", + "length": 40, + "read_only": 1 + }, + { + "fieldname": "currency", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Currency", + "length": 5, + "read_only": 1 + }, + { + "fieldname": "status", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Status", + "length": 5, + "read_only": 1, + "description": "A = Active, C = Closed" + }, + { + "fieldname": "account_type", + "fieldtype": "Data", + "label": "Type", + "length": 5, + "read_only": 1 + }, + { + "fieldname": "dates_section", + "fieldtype": "Section Break", + "label": "Dates" + }, + {"fieldname": "registration_date", "fieldtype": "Date", "label": "Registration Date", "read_only": 1}, + {"fieldname": "duplicate_date", "fieldtype": "Date", "label": "Duplicate Date", "read_only": 1}, + {"fieldname": "bank_section", "fieldtype": "Section Break", "label": "Bank"}, + {"fieldname": "bank_name", "fieldtype": "Data", "in_list_view": 1, "label": "Bank Name", "length": 500, "read_only": 1}, + {"fieldname": "bank_tin", "fieldtype": "Data", "label": "Bank TIN", "length": 20, "read_only": 1}, + {"fieldname": "bank_code", "fieldtype": "Data", "label": "Bank Code", "length": 20, "read_only": 1}, + {"fieldname": "main_bank_code", "fieldtype": "Data", "label": "Main Bank Code", "length": 20, "read_only": 1}, + {"fieldname": "settlement_account", "fieldtype": "Data", "label": "Settlement Account", "length": 40, "read_only": 1}, + {"fieldname": "company_section", "fieldtype": "Section Break", "label": "Link"}, + {"fieldname": "company", "fieldtype": "Link", "in_standard_filter": 1, "label": "Company", "options": "Company", "read_only": 1} + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-04-21 12:00:00", + "modified_by": "Administrator", + "module": "Invoice Az", + "name": "E-Taxes Bank Account", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + {"create": 1, "delete": 1, "email": 1, "export": 1, "print": 1, "read": 1, "report": 1, "role": "System Manager", "share": 1, "write": 1}, + {"create": 1, "email": 1, "export": 1, "print": 1, "read": 1, "report": 1, "role": "Accounts User", "share": 1, "write": 1} + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1, + "title_field": "number" +} diff --git a/invoice_az/invoice_az/doctype/e_taxes_bank_account/e_taxes_bank_account.py b/invoice_az/invoice_az/doctype/e_taxes_bank_account/e_taxes_bank_account.py new file mode 100644 index 0000000..2e28066 --- /dev/null +++ b/invoice_az/invoice_az/doctype/e_taxes_bank_account/e_taxes_bank_account.py @@ -0,0 +1,8 @@ +# Copyright (c) 2025, Jey ERP and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class ETaxesBankAccount(Document): + pass diff --git a/invoice_az/invoice_az/doctype/e_taxes_bank_account/test_e_taxes_bank_account.py b/invoice_az/invoice_az/doctype/e_taxes_bank_account/test_e_taxes_bank_account.py new file mode 100644 index 0000000..2cb848b --- /dev/null +++ b/invoice_az/invoice_az/doctype/e_taxes_bank_account/test_e_taxes_bank_account.py @@ -0,0 +1,16 @@ +# Copyright (c) 2025, Jey ERP and Contributors +# See license.txt + +from frappe.tests import IntegrationTestCase, UnitTestCase + + +EXTRA_TEST_RECORD_DEPENDENCIES = [] +IGNORE_TEST_RECORD_DEPENDENCIES = [] + + +class UnitTestETaxesBankAccount(UnitTestCase): + pass + + +class IntegrationTestETaxesBankAccount(IntegrationTestCase): + pass diff --git a/invoice_az/invoice_az/doctype/e_taxes_cash_register/__init__.py b/invoice_az/invoice_az/doctype/e_taxes_cash_register/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/invoice_az/invoice_az/doctype/e_taxes_cash_register/e_taxes_cash_register.json b/invoice_az/invoice_az/doctype/e_taxes_cash_register/e_taxes_cash_register.json new file mode 100644 index 0000000..703d415 --- /dev/null +++ b/invoice_az/invoice_az/doctype/e_taxes_cash_register/e_taxes_cash_register.json @@ -0,0 +1,192 @@ +{ + "actions": [], + "autoname": "field:token", + "creation": "2026-04-21 12:00:00", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "token", + "number", + "model", + "ecash_status", + "status_code", + "device_section", + "object", + "certificate_type", + "dates_section", + "installed_at", + "certificate_from", + "certificate_to", + "replacement_date", + "operator_section", + "operator_tin", + "operator_name", + "company_section", + "company" + ], + "fields": [ + { + "fieldname": "token", + "fieldtype": "Data", + "label": "Token", + "length": 140, + "read_only": 1, + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "number", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Serial Number", + "length": 140, + "read_only": 1 + }, + { + "fieldname": "model", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Model", + "length": 140, + "read_only": 1 + }, + { + "fieldname": "ecash_status", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "E-Cash Status", + "options": "\nACTIVE\nELIMINATED", + "read_only": 1 + }, + { + "fieldname": "status_code", + "fieldtype": "Data", + "label": "Status Code", + "length": 10, + "read_only": 1 + }, + { + "fieldname": "device_section", + "fieldtype": "Section Break", + "label": "Device" + }, + { + "fieldname": "object", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Object", + "options": "E-Taxes Object", + "read_only": 1 + }, + { + "fieldname": "certificate_type", + "fieldtype": "Data", + "label": "Certificate Type", + "length": 140, + "read_only": 1 + }, + { + "fieldname": "dates_section", + "fieldtype": "Section Break", + "label": "Dates" + }, + { + "fieldname": "installed_at", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Installed At", + "read_only": 1 + }, + { + "fieldname": "certificate_from", + "fieldtype": "Date", + "label": "Certificate From", + "read_only": 1 + }, + { + "fieldname": "certificate_to", + "fieldtype": "Date", + "label": "Certificate To", + "read_only": 1 + }, + { + "fieldname": "replacement_date", + "fieldtype": "Date", + "label": "Replacement Date", + "read_only": 1 + }, + { + "fieldname": "operator_section", + "fieldtype": "Section Break", + "label": "Operator" + }, + { + "fieldname": "operator_tin", + "fieldtype": "Data", + "label": "Operator TIN", + "length": 20, + "read_only": 1 + }, + { + "fieldname": "operator_name", + "fieldtype": "Data", + "label": "Operator Name", + "length": 500, + "read_only": 1 + }, + { + "fieldname": "company_section", + "fieldtype": "Section Break", + "label": "Link" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "in_standard_filter": 1, + "label": "Company", + "options": "Company", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-04-21 12:00:00", + "modified_by": "Administrator", + "module": "Invoice Az", + "name": "E-Taxes Cash Register", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1, + "title_field": "number" +} diff --git a/invoice_az/invoice_az/doctype/e_taxes_cash_register/e_taxes_cash_register.py b/invoice_az/invoice_az/doctype/e_taxes_cash_register/e_taxes_cash_register.py new file mode 100644 index 0000000..341a1ec --- /dev/null +++ b/invoice_az/invoice_az/doctype/e_taxes_cash_register/e_taxes_cash_register.py @@ -0,0 +1,8 @@ +# Copyright (c) 2025, Jey ERP and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class ETaxesCashRegister(Document): + pass diff --git a/invoice_az/invoice_az/doctype/e_taxes_cash_register/test_e_taxes_cash_register.py b/invoice_az/invoice_az/doctype/e_taxes_cash_register/test_e_taxes_cash_register.py new file mode 100644 index 0000000..79fece3 --- /dev/null +++ b/invoice_az/invoice_az/doctype/e_taxes_cash_register/test_e_taxes_cash_register.py @@ -0,0 +1,16 @@ +# Copyright (c) 2025, Jey ERP and Contributors +# See license.txt + +from frappe.tests import IntegrationTestCase, UnitTestCase + + +EXTRA_TEST_RECORD_DEPENDENCIES = [] +IGNORE_TEST_RECORD_DEPENDENCIES = [] + + +class UnitTestETaxesCashRegister(UnitTestCase): + pass + + +class IntegrationTestETaxesCashRegister(IntegrationTestCase): + pass diff --git a/invoice_az/invoice_az/doctype/e_taxes_object/__init__.py b/invoice_az/invoice_az/doctype/e_taxes_object/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/invoice_az/invoice_az/doctype/e_taxes_object/e_taxes_object.json b/invoice_az/invoice_az/doctype/e_taxes_object/e_taxes_object.json new file mode 100644 index 0000000..2024f0c --- /dev/null +++ b/invoice_az/invoice_az/doctype/e_taxes_object/e_taxes_object.json @@ -0,0 +1,174 @@ +{ + "actions": [], + "autoname": "field:object_code", + "creation": "2026-04-21 12:00:00", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "object_code", + "object_name", + "object_status", + "address", + "activity_section", + "main_activity_code", + "main_activity_name", + "classification_section", + "main_class_code", + "main_class_name", + "middle_class_code", + "middle_class_name", + "sub_class_code", + "sub_class_name", + "company_section", + "company" + ], + "fields": [ + { + "fieldname": "object_code", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Object Code", + "length": 140, + "read_only": 1, + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "object_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Object Name", + "length": 500, + "read_only": 1 + }, + { + "default": "active", + "fieldname": "object_status", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Status", + "options": "active\nliquidated", + "read_only": 1 + }, + { + "fieldname": "address", + "fieldtype": "Small Text", + "label": "Address", + "read_only": 1 + }, + { + "fieldname": "activity_section", + "fieldtype": "Section Break", + "label": "Activity" + }, + { + "fieldname": "main_activity_code", + "fieldtype": "Data", + "label": "Main Activity Code", + "read_only": 1 + }, + { + "fieldname": "main_activity_name", + "fieldtype": "Data", + "label": "Main Activity Name", + "length": 500, + "read_only": 1 + }, + { + "fieldname": "classification_section", + "fieldtype": "Section Break", + "label": "Classification" + }, + { + "fieldname": "main_class_code", + "fieldtype": "Data", + "label": "Main Class Code", + "read_only": 1 + }, + { + "fieldname": "main_class_name", + "fieldtype": "Data", + "label": "Main Class Name", + "length": 500, + "read_only": 1 + }, + { + "fieldname": "middle_class_code", + "fieldtype": "Data", + "label": "Middle Class Code", + "read_only": 1 + }, + { + "fieldname": "middle_class_name", + "fieldtype": "Data", + "label": "Middle Class Name", + "length": 500, + "read_only": 1 + }, + { + "fieldname": "sub_class_code", + "fieldtype": "Data", + "label": "Sub Class Code", + "read_only": 1 + }, + { + "fieldname": "sub_class_name", + "fieldtype": "Data", + "label": "Sub Class Name", + "length": 500, + "read_only": 1 + }, + { + "fieldname": "company_section", + "fieldtype": "Section Break", + "label": "Link" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "in_standard_filter": 1, + "label": "Company", + "options": "Company", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-04-21 12:00:00", + "modified_by": "Administrator", + "module": "Invoice Az", + "name": "E-Taxes Object", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/invoice_az/invoice_az/doctype/e_taxes_object/e_taxes_object.py b/invoice_az/invoice_az/doctype/e_taxes_object/e_taxes_object.py new file mode 100644 index 0000000..e28ab21 --- /dev/null +++ b/invoice_az/invoice_az/doctype/e_taxes_object/e_taxes_object.py @@ -0,0 +1,8 @@ +# Copyright (c) 2025, Jey ERP and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class ETaxesObject(Document): + pass diff --git a/invoice_az/invoice_az/doctype/e_taxes_object/test_e_taxes_object.py b/invoice_az/invoice_az/doctype/e_taxes_object/test_e_taxes_object.py new file mode 100644 index 0000000..6dd8979 --- /dev/null +++ b/invoice_az/invoice_az/doctype/e_taxes_object/test_e_taxes_object.py @@ -0,0 +1,16 @@ +# Copyright (c) 2025, Jey ERP and Contributors +# See license.txt + +from frappe.tests import IntegrationTestCase, UnitTestCase + + +EXTRA_TEST_RECORD_DEPENDENCIES = [] +IGNORE_TEST_RECORD_DEPENDENCIES = [] + + +class UnitTestETaxesObject(UnitTestCase): + pass + + +class IntegrationTestETaxesObject(IntegrationTestCase): + pass diff --git a/invoice_az/invoice_az/doctype/e_taxes_obligation_pact/__init__.py b/invoice_az/invoice_az/doctype/e_taxes_obligation_pact/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/invoice_az/invoice_az/doctype/e_taxes_obligation_pact/e_taxes_obligation_pact.json b/invoice_az/invoice_az/doctype/e_taxes_obligation_pact/e_taxes_obligation_pact.json new file mode 100644 index 0000000..988876b --- /dev/null +++ b/invoice_az/invoice_az/doctype/e_taxes_obligation_pact/e_taxes_obligation_pact.json @@ -0,0 +1,80 @@ +{ + "actions": [], + "autoname": "field:code", + "creation": "2026-04-21 12:00:00", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "code", + "name_az", + "validity_date", + "expire_date", + "company_section", + "company" + ], + "fields": [ + { + "fieldname": "code", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Code", + "length": 40, + "read_only": 1, + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "name_az", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Name (AZ)", + "length": 500, + "read_only": 1 + }, + { + "fieldname": "validity_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Validity Date", + "read_only": 1 + }, + { + "fieldname": "expire_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Expire Date", + "read_only": 1 + }, + { + "fieldname": "company_section", + "fieldtype": "Section Break", + "label": "Link" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "in_standard_filter": 1, + "label": "Company", + "options": "Company", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-04-21 12:00:00", + "modified_by": "Administrator", + "module": "Invoice Az", + "name": "E-Taxes Obligation Pact", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + {"create": 1, "delete": 1, "email": 1, "export": 1, "print": 1, "read": 1, "report": 1, "role": "System Manager", "share": 1, "write": 1}, + {"create": 1, "email": 1, "export": 1, "print": 1, "read": 1, "report": 1, "role": "Accounts User", "share": 1, "write": 1} + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1, + "title_field": "name_az" +} diff --git a/invoice_az/invoice_az/doctype/e_taxes_obligation_pact/e_taxes_obligation_pact.py b/invoice_az/invoice_az/doctype/e_taxes_obligation_pact/e_taxes_obligation_pact.py new file mode 100644 index 0000000..5b40edb --- /dev/null +++ b/invoice_az/invoice_az/doctype/e_taxes_obligation_pact/e_taxes_obligation_pact.py @@ -0,0 +1,8 @@ +# Copyright (c) 2025, Jey ERP and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class ETaxesObligationPact(Document): + pass diff --git a/invoice_az/invoice_az/doctype/e_taxes_obligation_pact/test_e_taxes_obligation_pact.py b/invoice_az/invoice_az/doctype/e_taxes_obligation_pact/test_e_taxes_obligation_pact.py new file mode 100644 index 0000000..faad80a --- /dev/null +++ b/invoice_az/invoice_az/doctype/e_taxes_obligation_pact/test_e_taxes_obligation_pact.py @@ -0,0 +1,16 @@ +# Copyright (c) 2025, Jey ERP and Contributors +# See license.txt + +from frappe.tests import IntegrationTestCase, UnitTestCase + + +EXTRA_TEST_RECORD_DEPENDENCIES = [] +IGNORE_TEST_RECORD_DEPENDENCIES = [] + + +class UnitTestETaxesObligationPact(UnitTestCase): + pass + + +class IntegrationTestETaxesObligationPact(IntegrationTestCase): + pass diff --git a/invoice_az/invoice_az/doctype/e_taxes_pos_terminal/__init__.py b/invoice_az/invoice_az/doctype/e_taxes_pos_terminal/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/invoice_az/invoice_az/doctype/e_taxes_pos_terminal/e_taxes_pos_terminal.json b/invoice_az/invoice_az/doctype/e_taxes_pos_terminal/e_taxes_pos_terminal.json new file mode 100644 index 0000000..e963974 --- /dev/null +++ b/invoice_az/invoice_az/doctype/e_taxes_pos_terminal/e_taxes_pos_terminal.json @@ -0,0 +1,127 @@ +{ + "actions": [], + "autoname": "field:registration_number", + "creation": "2026-04-21 12:00:00", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "registration_number", + "serial_number", + "status", + "registration_date", + "device_section", + "object", + "bank_name", + "company_section", + "company" + ], + "fields": [ + { + "fieldname": "registration_number", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Registration Number", + "length": 140, + "read_only": 1, + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "serial_number", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Serial Number", + "length": 140, + "read_only": 1 + }, + { + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Status", + "options": "\nA\nC", + "read_only": 1, + "description": "A = Active, C = Closed" + }, + { + "fieldname": "registration_date", + "fieldtype": "Date", + "in_list_view": 1, + "label": "Registration Date", + "read_only": 1 + }, + { + "fieldname": "device_section", + "fieldtype": "Section Break", + "label": "Device" + }, + { + "fieldname": "object", + "fieldtype": "Link", + "in_standard_filter": 1, + "label": "Object", + "options": "E-Taxes Object", + "read_only": 1 + }, + { + "fieldname": "bank_name", + "fieldtype": "Data", + "label": "Bank Name", + "length": 500, + "read_only": 1 + }, + { + "fieldname": "company_section", + "fieldtype": "Section Break", + "label": "Link" + }, + { + "fieldname": "company", + "fieldtype": "Link", + "in_standard_filter": 1, + "label": "Company", + "options": "Company", + "read_only": 1 + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-04-21 12:00:00", + "modified_by": "Administrator", + "module": "Invoice Az", + "name": "E-Taxes POS Terminal", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Accounts User", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1, + "title_field": "serial_number" +} diff --git a/invoice_az/invoice_az/doctype/e_taxes_pos_terminal/e_taxes_pos_terminal.py b/invoice_az/invoice_az/doctype/e_taxes_pos_terminal/e_taxes_pos_terminal.py new file mode 100644 index 0000000..89e2712 --- /dev/null +++ b/invoice_az/invoice_az/doctype/e_taxes_pos_terminal/e_taxes_pos_terminal.py @@ -0,0 +1,8 @@ +# Copyright (c) 2025, Jey ERP and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class ETaxesPOSTerminal(Document): + pass diff --git a/invoice_az/invoice_az/doctype/e_taxes_pos_terminal/test_e_taxes_pos_terminal.py b/invoice_az/invoice_az/doctype/e_taxes_pos_terminal/test_e_taxes_pos_terminal.py new file mode 100644 index 0000000..bff052e --- /dev/null +++ b/invoice_az/invoice_az/doctype/e_taxes_pos_terminal/test_e_taxes_pos_terminal.py @@ -0,0 +1,16 @@ +# Copyright (c) 2025, Jey ERP and Contributors +# See license.txt + +from frappe.tests import IntegrationTestCase, UnitTestCase + + +EXTRA_TEST_RECORD_DEPENDENCIES = [] +IGNORE_TEST_RECORD_DEPENDENCIES = [] + + +class UnitTestETaxesPOSTerminal(UnitTestCase): + pass + + +class IntegrationTestETaxesPOSTerminal(IntegrationTestCase): + pass diff --git a/invoice_az/invoice_az/doctype/e_taxes_presented_certificate/__init__.py b/invoice_az/invoice_az/doctype/e_taxes_presented_certificate/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/invoice_az/invoice_az/doctype/e_taxes_presented_certificate/e_taxes_presented_certificate.json b/invoice_az/invoice_az/doctype/e_taxes_presented_certificate/e_taxes_presented_certificate.json new file mode 100644 index 0000000..e23bf6e --- /dev/null +++ b/invoice_az/invoice_az/doctype/e_taxes_presented_certificate/e_taxes_presented_certificate.json @@ -0,0 +1,141 @@ +{ + "actions": [], + "autoname": "field:oid", + "creation": "2026-04-21 12:00:00", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "oid", + "cert_no", + "cert_category", + "cert_state", + "operation_date", + "taxpayer_section", + "taxpayer_full_name", + "voen", + "sazis_section", + "sazis_name", + "sazis_code", + "contract_section", + "contract_start_date", + "contract_end_date", + "cert_start_date", + "cert_end_date", + "misc_section", + "fk_doc_oid", + "company_section", + "company" + ], + "fields": [ + { + "fieldname": "oid", + "fieldtype": "Data", + "label": "OID", + "length": 40, + "read_only": 1, + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "cert_no", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Certificate No", + "length": 40, + "read_only": 1 + }, + { + "fieldname": "cert_category", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Category", + "options": "\nACCEPTED\nPRESENTED\nREJECTED\nPENDING", + "read_only": 1 + }, + { + "fieldname": "cert_state", + "fieldtype": "Data", + "label": "State", + "length": 40, + "read_only": 1 + }, + { + "fieldname": "operation_date", + "fieldtype": "Datetime", + "label": "Operation Date", + "read_only": 1 + }, + { + "fieldname": "taxpayer_section", + "fieldtype": "Section Break", + "label": "Taxpayer" + }, + { + "fieldname": "taxpayer_full_name", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Taxpayer Full Name", + "length": 500, + "read_only": 1 + }, + { + "fieldname": "voen", + "fieldtype": "Data", + "label": "VOEN (Tax ID)", + "length": 20, + "read_only": 1 + }, + { + "fieldname": "sazis_section", + "fieldtype": "Section Break", + "label": "Agreement (Sazış)" + }, + { + "fieldname": "sazis_name", + "fieldtype": "Data", + "label": "Agreement Name", + "length": 500, + "read_only": 1 + }, + { + "fieldname": "sazis_code", + "fieldtype": "Link", + "in_standard_filter": 1, + "label": "Agreement Code", + "options": "E-Taxes Obligation Pact", + "read_only": 1 + }, + { + "fieldname": "contract_section", + "fieldtype": "Section Break", + "label": "Validity" + }, + {"fieldname": "contract_start_date", "fieldtype": "Date", "label": "Contract Start", "read_only": 1}, + {"fieldname": "contract_end_date", "fieldtype": "Date", "label": "Contract End", "read_only": 1}, + {"fieldname": "cert_start_date", "fieldtype": "Date", "label": "Certificate Start", "read_only": 1}, + {"fieldname": "cert_end_date", "fieldtype": "Date", "label": "Certificate End", "read_only": 1}, + {"fieldname": "misc_section", "fieldtype": "Section Break", "label": "Misc"}, + {"fieldname": "fk_doc_oid", "fieldtype": "Data", "label": "FK Doc OID", "length": 40, "read_only": 1}, + {"fieldname": "company_section", "fieldtype": "Section Break", "label": "Link"}, + {"fieldname": "company", "fieldtype": "Link", "in_standard_filter": 1, "label": "Company", "options": "Company", "read_only": 1} + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-04-21 12:00:00", + "modified_by": "Administrator", + "module": "Invoice Az", + "name": "E-Taxes Presented Certificate", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + {"create": 1, "delete": 1, "email": 1, "export": 1, "print": 1, "read": 1, "report": 1, "role": "System Manager", "share": 1, "write": 1}, + {"create": 1, "email": 1, "export": 1, "print": 1, "read": 1, "report": 1, "role": "Accounts User", "share": 1, "write": 1} + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1, + "title_field": "cert_no" +} diff --git a/invoice_az/invoice_az/doctype/e_taxes_presented_certificate/e_taxes_presented_certificate.py b/invoice_az/invoice_az/doctype/e_taxes_presented_certificate/e_taxes_presented_certificate.py new file mode 100644 index 0000000..8bb5375 --- /dev/null +++ b/invoice_az/invoice_az/doctype/e_taxes_presented_certificate/e_taxes_presented_certificate.py @@ -0,0 +1,8 @@ +# Copyright (c) 2025, Jey ERP and contributors +# For license information, please see license.txt + +from frappe.model.document import Document + + +class ETaxesPresentedCertificate(Document): + pass diff --git a/invoice_az/invoice_az/doctype/e_taxes_presented_certificate/test_e_taxes_presented_certificate.py b/invoice_az/invoice_az/doctype/e_taxes_presented_certificate/test_e_taxes_presented_certificate.py new file mode 100644 index 0000000..2abc482 --- /dev/null +++ b/invoice_az/invoice_az/doctype/e_taxes_presented_certificate/test_e_taxes_presented_certificate.py @@ -0,0 +1,16 @@ +# Copyright (c) 2025, Jey ERP and Contributors +# See license.txt + +from frappe.tests import IntegrationTestCase, UnitTestCase + + +EXTRA_TEST_RECORD_DEPENDENCIES = [] +IGNORE_TEST_RECORD_DEPENDENCIES = [] + + +class UnitTestETaxesPresentedCertificate(UnitTestCase): + pass + + +class IntegrationTestETaxesPresentedCertificate(IntegrationTestCase): + pass