Added purchase invoice sending (act)

This commit is contained in:
Ali 2026-01-27 20:16:12 +04:00
parent f1ae840d88
commit 4879dcc94c
10 changed files with 3347 additions and 46 deletions

326
CLAUDE.md
View File

@ -11,6 +11,332 @@ Invoice Az is a Frappe application that integrates with Azerbaijan's e-taxes.gov
- Importing VAT Account operations and creating Journal Entries
- Managing authentication via ASAN Login with automatic token renewal
## ⚠️ CRITICAL RULES FOR E-TAXES INTEGRATION ⚠️
**READ THIS BEFORE IMPLEMENTING ANY E-TAXES API CALLS!**
### 1. Authentication Pattern (MANDATORY)
**❌ WRONG - Direct API call:**
```javascript
function my_function(frm) {
frappe.call({
method: 'invoice_az.my_api.some_method',
// ... this will fail with "Authentication expired"
});
}
```
**✅ CORRECT - Always wrap with authentication check:**
```javascript
function my_function(frm) {
ETaxes.auth.checkAndProcess(function() {
// Your code here - only runs after auth is verified
frappe.call({
method: 'invoice_az.my_api.some_method',
// ... works correctly
});
});
}
```
**Why?** `ETaxes.auth.checkAndProcess()` does:
1. Checks if token is still valid
2. If expired → prompts user to login via ASAN Imza
3. Only after successful auth → executes your callback
4. Prevents "Authentication expired" errors
### 2. ETaxes Module Inclusion (MANDATORY)
**⚠️ ETaxes is NOT a global module!** It must be defined in EACH JavaScript file that uses it.
**❌ WRONG - Assuming ETaxes exists:**
```javascript
// my_custom_form.js
frappe.ui.form.on('My DocType', {
refresh: function(frm) {
ETaxes.auth.checkAndProcess(function() {
// ERROR: ETaxes is not defined
});
}
});
```
**✅ CORRECT - Include ETaxes module at the top of your JS file:**
Copy the entire ETaxes module from `sales_invoice.js` (lines 1-564) to the **beginning** of your JS file:
```javascript
// ======= COPY THIS ENTIRE BLOCK =======
// From sales_invoice.js lines 1-564:
const ETaxes = {
PROGRESS_UPDATE_DELAY: 50,
AUTH_POLL_INTERVAL: 6000,
AUTH_MAX_ATTEMPTS: 20,
// ... (full module definition)
};
ETaxes.utils = { /* ... */ };
ETaxes.dialogs = { /* ... */ };
ETaxes.auth = { /* ... */ };
// ======= END COPY =======
// Now your code can use ETaxes:
frappe.ui.form.on('My DocType', {
refresh: function(frm) {
ETaxes.auth.checkAndProcess(function() {
// ✅ Works correctly
});
}
});
```
**Files that need ETaxes module:**
- ✅ `sales_invoice.js` - has it (564 lines)
- ✅ `purchase_order.js` - has it
- ✅ `supplier.js` - has it (after fix)
- ❌ Any new JS file - **MUST include it!**
### 3. Backend API Pattern (MANDATORY)
**All backend functions that call E-Taxes API MUST follow this pattern:**
```python
import frappe
import requests
from invoice_az.auth import record_etaxes_activity, get_default_asan_login
@frappe.whitelist()
def my_etaxes_function(doc_name):
"""My E-Taxes API function"""
try:
# Step 1: ALWAYS record activity (for token renewal)
record_etaxes_activity()
# Step 2: Get document
doc = frappe.get_doc("My DocType", doc_name)
# Step 3: Validate (business logic checks)
validation = validate_my_document(doc)
if not validation.get("valid"):
return {
"success": False,
"message": validation.get("message")
}
# Step 4: Get authentication token
asan_login = get_default_asan_login()
if not asan_login.get("found") or not asan_login.get("main_token"):
return {
"success": False,
"message": "E-Taxes authentication not found. Please login via ASAN."
}
token = asan_login.get("main_token")
# Step 5: Build headers with token
headers = DEFAULT_HEADERS.copy()
headers["x-authorization"] = f"Bearer {token}"
# Step 6: Make API request
response = requests.post(
"https://new.e-taxes.gov.az/api/...",
data=json.dumps(payload),
headers=headers,
timeout=60
)
# Step 7: Handle common errors
if response.status_code == 401:
return {
"success": False,
"message": "Authentication expired. Please login again."
}
if response.status_code == 500:
return {
"success": False,
"message": "E-Taxes service error. Please try again later."
}
if response.status_code == 404:
return {
"success": False,
"message": "Resource not found."
}
response.raise_for_status()
# Step 8: Process response
result = response.json()
# Step 9: 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)}"
}
except Exception as e:
frappe.log_error(
f"Unexpected error: {str(e)}\n{frappe.get_traceback()}",
"E-Taxes API Error"
)
return {
"success": False,
"message": f"Error: {str(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
### 4. Constants and URLs
**Base URL:**
```python
BASE_URL = "https://new.e-taxes.gov.az"
```
**Common endpoints:**
- Auth: `/api/po/auth/public/v1/*`
- Invoices: `/api/po/invoice/public/v2/invoice`
- Sign: `/api/po/invoice/public/v1/invoice/sign/withAsanImza`
- Serial: `/api/po/invoice/public/v1/generateSerialNumber/defaultInvoice`
- Agricultural Serial: `/api/po/invoice/public/v1/act/generateSerialNumber/agriculturalProductsAct`
- Agricultural Act: `/api/po/invoice/public/v1/act`
- Remove Drafts: `/api/po/invoice/public/v1/common/removeDrafts`
- VAT Operations: `/api/po/vatacc/public/v1/operation/find.outbox`
- Taxpayer by FIN: `/api/po/profile/public/v1/taxpayer/findByFinAndPassport`
**Standard headers:**
```python
DEFAULT_HEADERS = {
"Accept": "application/json, text/plain, */*",
"Accept-Language": "en-US,en;q=0.9",
"Content-Type": "application/json",
"Cache-Control": "no-cache",
"Pragma": "no-cache"
}
```
### 5. Error Handling
**HTTP Status Codes:**
- `200` - Success
- `401` - Token expired → Show "Please login again"
- `404` - Resource not found
- `500` - E-Taxes server error → Show "Try again later"
**Always check in this order:**
```python
if response.status_code == 401:
return {"success": False, "message": "Authentication expired. Please login again."}
if response.status_code == 500:
return {"success": False, "message": "E-Taxes service error. Please try again later."}
if response.status_code == 404:
return {"success": False, "message": "Not found"}
response.raise_for_status() # For other errors
```
### 6. Frontend Button Pattern
**Always show buttons conditionally:**
```javascript
frappe.ui.form.on('My DocType', {
refresh: function(frm) {
// Only for submitted documents
if (frm.doc.docstatus === 1) {
add_etaxes_buttons(frm);
}
}
});
function add_etaxes_buttons(frm) {
// Check status and show appropriate buttons
if (frm.doc.etaxes_status === 'Sent and Signed') {
frm.add_custom_button(__('View Details'), function() {
// Show details
}, __('E-Taxes'));
} else if (frm.doc.etaxes_status === 'Created, not signed') {
frm.add_custom_button(__('Sign with ASAN Imza'), function() {
// Sign document
}, __('E-Taxes'));
} else {
frm.add_custom_button(__('Send to E-Taxes'), function() {
// Send to E-Taxes
}, __('E-Taxes'));
}
}
```
### 7. Build Process
**After modifying JavaScript files:**
```bash
# Build assets
cd /home/frappe/frappe-bench
bench build --app invoice_az
# Clear cache
bench --site site1 clear-cache
```
**After modifying Python files:**
```bash
# Just clear cache (no build needed)
bench --site site1 clear-cache
```
### 8. Common Mistakes to Avoid
❌ **Don't do this:**
1. Call E-Taxes API without `ETaxes.auth.checkAndProcess()`
2. Forget to include ETaxes module in new JS files
3. Skip `record_etaxes_activity()` in backend functions
4. Forget to handle 401/500/404 errors
5. Use `app_include_js` in hooks.py (doesn't work for doctype-specific code)
6. Make API calls without Bearer token in headers
✅ **Always do this:**
1. Wrap ALL E-Taxes operations in `ETaxes.auth.checkAndProcess()`
2. Copy ETaxes module (564 lines) to every new JS file
3. Call `record_etaxes_activity()` first in every API function
4. Handle 401, 500, 404 explicitly
5. Return consistent `{success, message}` format
6. Add `x-authorization: Bearer {token}` header
7. Build assets after JS changes
### 9. Example Files to Reference
**Backend (Python):**
- ✅ `send_sales_api.py` - Perfect example of all patterns
- ✅ `send_purchase_api.py` - Agricultural act example
- ✅ `supplier_api.py` - Simple API call example
- ✅ `auth.py` - Authentication implementation
**Frontend (JavaScript):**
- ✅ `sales_invoice.js` - Complete ETaxes module (lines 1-564)
- ✅ `purchase_invoice.js` - Button patterns
- ✅ `supplier.js` - Minimal working example
**When in doubt:** Look at `send_sales_api.py` + `sales_invoice.js` - they contain all correct patterns!
## Development Commands
### Linting and Code Quality

View File

@ -1,26 +1,908 @@
/**
* Purchase Invoice Form and List Configuration
* Optimized version using ETaxes common module patterns
* Includes Agricultural Act sending functionality for Individual suppliers
*/
// ======= ETAXES MODULE (REQUIRED FOR AUTHENTICATION) =======
// This module MUST be included in every JS file that calls E-Taxes API
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(`
<div style="width: 64px; height: 64px; display: flex; align-items: center; justify-content: center;">
<i class="fa fa-check" style="font-size: 48px; color: #5cb85c;"></i>
</div>
`);
$('#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;
// Update dialog title
ETaxes.loadingDialog.set_title(__('Error'));
// Change animation to error icon
ETaxes.loadingDialog.$wrapper.find('.loading-animation').html(`
<div style="width: 64px; height: 64px; display: flex; align-items: center; justify-content: center;">
<i class="fa fa-times" style="font-size: 48px; color: #d9534f;"></i>
</div>
`);
// Clear all existing text elements
ETaxes.loadingDialog.$wrapper.find('#loading_title').text('');
ETaxes.loadingDialog.$wrapper.find('#loading_submessage').remove();
// Set error message
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, '<br>'));
} 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 `
<div class="text-center">
<div class="loading-animation" style="display: inline-block; width: 64px; height: 64px;">
<div style="width: 64px; height: 64px; border: 6px solid #4e73df; border-radius: 50%; border-color: #4e73df transparent #4e73df transparent; animation: lds-dual-ring 1.2s linear infinite;"></div>
</div>
<style>
@keyframes lds-dual-ring {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
<div class="mt-3" id="status_message">
<h4 id="loading_title">${title || __('Please wait...')}</h4>
<p id="loading_message">${message || __('The operation may take some time')}</p>
<p id="loading_submessage" style="${submessage ? '' : 'display:none'}">${submessage || ''}</p>
</div>
<div id="verification_code_container" style="display:none; margin-top: 15px;">
<div style="background: #f8f9fa; padding: 10px; display: inline-block; border-radius: 5px; font-size: 24px; font-weight: bold; letter-spacing: 2px; color: #333; border: 1px solid #ddd;">
<span id="verification_code">----</span>
</div>
<p style="margin-top: 5px; font-size: 12px; color: #666;">Verification Code</p>
</div>
</div>
`;
}
};
// ======= МОДУЛЬ АУТЕНТИФИКАЦИИ =======
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 = '<div style="max-height: 400px; overflow-y: auto;"><table class="table table-bordered">';
certHtml += '<thead><tr><th>Type</th><th>Name</th><th>ID</th><th>Position</th><th>Status</th><th></th></tr></thead><tbody>';
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 += '<tr>' +
'<td>' + (cert.taxpayerType || '') + '</td>' +
'<td>' + name + '</td>' +
'<td>' + id + '</td>' +
'<td>' + (cert.position || '') + '</td>' +
'<td>' + (cert.hasAccess ? 'Active' : 'Inactive') + (cert.liquidated ? ' (Liquidated)' : '') + '</td>' +
'<td><button class="btn btn-xs btn-primary select-cert" data-index="' + index + '">Select</button></td>' +
'</tr>';
});
certHtml += '</tbody></table></div>';
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 =======
frappe.ui.form.on('Purchase Invoice', {
refresh: function(frm) {
// Check if is_taxes_doc flag is set
if (frm.doc.is_taxes_doc) {
// Make all fields read-only
frm.set_read_only(true);
// Add visual indicator
frm.page.set_indicator(__('Tax Document'), 'blue');
}
// Add E-Taxes buttons for Agricultural Acts (only for Individual suppliers)
if (frm.doc.docstatus === 1) {
add_agricultural_act_buttons(frm);
}
// Add status indicator based on send status
add_status_indicator(frm);
},
// Make is_taxes_doc field read-only when document loads
onload: function(frm) {
frm.set_df_property('is_taxes_doc', 'read_only', 1);
}
});
/**
* Add status indicator based on E-Taxes send status
*/
function add_status_indicator(frm) {
if (!frm.doc.etaxes_purchase_send_status) return;
const status_colors = {
'Sent and Signed': 'green',
'Created, not signed': 'orange',
'Failed': 'red',
'Not Sent': 'gray'
};
const color = status_colors[frm.doc.etaxes_purchase_send_status] || 'gray';
if (frm.doc.etaxes_purchase_send_status !== 'Not Sent') {
frm.page.set_indicator(__(frm.doc.etaxes_purchase_send_status), color);
}
}
/**
* Add Agricultural Act buttons for Individual suppliers
*/
function add_agricultural_act_buttons(frm) {
// Check if supplier is Individual type
if (!frm.doc.supplier) return;
frappe.db.get_value('Supplier', frm.doc.supplier, 'supplier_type', function(r) {
if (r && r.supplier_type === 'Individual') {
// Show buttons based on status
// Status: Sent and Signed - show details only
if (frm.doc.etaxes_purchase_send_status === 'Sent and Signed') {
frm.add_custom_button(__('View E-Taxes Details'), function() {
show_act_details(frm);
}, __('E-Taxes Agricultural Act'));
return;
}
// Status: Created, not signed - show sign button
if (frm.doc.etaxes_purchase_send_status === 'Created, not signed') {
frm.add_custom_button(__('Sign Act with ASAN Imza'), function() {
sign_agricultural_act(frm);
}, __('E-Taxes Agricultural Act'));
frm.add_custom_button(__('Cancel Act on E-Taxes'), function() {
cancel_act_on_etaxes(frm);
}, __('E-Taxes Agricultural Act'));
return;
}
// Status: Not sent or Failed - show send button
if (!frm.doc.etaxes_purchase_send_status ||
frm.doc.etaxes_purchase_send_status === 'Not Sent' ||
frm.doc.etaxes_purchase_send_status === 'Failed') {
frm.add_custom_button(__('Send Agricultural Act to E-Taxes'), function() {
send_agricultural_act(frm);
}, __('E-Taxes Agricultural Act'));
// Show cancel button if act exists
if (frm.doc.etaxes_purchase_id) {
frm.add_custom_button(__('Cancel Act on E-Taxes'), function() {
cancel_act_on_etaxes(frm);
}, __('E-Taxes Agricultural Act'));
}
}
}
});
}
/**
* Send Agricultural Act to E-Taxes (Step 1: Create draft)
*/
function send_agricultural_act(frm) {
frappe.confirm(
__('Send Agricultural Act to E-Taxes?') + '<br><br>' +
__('Purchase Invoice: {0}', [frm.doc.name]) + '<br>' +
__('Supplier: {0}', [frm.doc.supplier_name]) + '<br><br>' +
__('This will create a draft act. You will need to sign it with ASAN Imza after creation.'),
function() {
// ✅ CRITICAL: Always wrap E-Taxes API calls with authentication check
ETaxes.auth.checkAndProcess(function() {
// Show loading
frappe.show_alert({
message: __('Creating agricultural act...'),
indicator: 'blue'
}, 3);
frappe.call({
method: 'invoice_az.send_purchase_api.send_purchase_invoice_to_etaxes',
args: {
purchase_invoice_name: frm.doc.name
},
callback: function(r) {
// Log full response to console for debugging
console.log('[PURCHASE_ACT] Full response from server:', r);
if (r.message && r.message.success) {
// Success
console.log('[PURCHASE_ACT] Success! Act created:', r.message);
frappe.show_alert({
message: __('Draft act created successfully!') + '<br>' +
__('Serial: {0}', [r.message.serial_number]),
indicator: 'green'
}, 5);
// Reload document
frm.reload_doc();
} else {
// Error
console.error('[PURCHASE_ACT] Error creating act:', r.message);
const errorMsg = r.message ? r.message.message : __('Failed to create agricultural act');
frappe.msgprint({
title: __('Error'),
indicator: 'red',
message: errorMsg
});
}
},
error: function(r) {
console.error('[PURCHASE_ACT] Communication error:', r);
frappe.msgprint({
title: __('Error'),
indicator: 'red',
message: __('Failed to communicate with E-Taxes service.')
});
}
});
});
}
);
}
/**
* Sign Agricultural Act with ASAN Imza (Step 2)
*/
function sign_agricultural_act(frm) {
frappe.confirm(
__('Sign Agricultural Act with ASAN Imza?') + '<br><br>' +
__('Purchase Invoice: {0}', [frm.doc.name]) + '<br>' +
__('Serial Number: {0}', [frm.doc.etaxes_purchase_serial_number]) + '<br>' +
__('E-Taxes Act ID: {0}', [frm.doc.etaxes_purchase_id]) + '<br><br>' +
__('This will finalize the act and send it to E-Taxes.'),
function() {
// ✅ CRITICAL: Always wrap E-Taxes API calls with authentication check
ETaxes.auth.checkAndProcess(function() {
// Show loading
frappe.show_alert({
message: __('Signing agricultural act...'),
indicator: 'blue'
}, 3);
frappe.call({
method: 'invoice_az.send_purchase_api.retry_signing_act',
args: {
purchase_invoice_name: frm.doc.name
},
callback: function(r) {
if (r.message && r.message.success) {
// Success
frappe.show_alert({
message: __('Act signed successfully!') + '<br>' +
__('Verification Code: {0}', [r.message.verification_code]),
indicator: 'green'
}, 5);
// Reload document
frm.reload_doc();
} else {
// Error
const errorMsg = r.message ? r.message.message : __('Failed to sign agricultural act');
frappe.msgprint({
title: __('Error'),
indicator: 'red',
message: errorMsg
});
}
},
error: function(r) {
frappe.msgprint({
title: __('Error'),
indicator: 'red',
message: __('Failed to communicate with E-Taxes service.')
});
}
});
});
}
);
}
/**
* Cancel/Remove draft act from E-Taxes
*/
function cancel_act_on_etaxes(frm) {
frappe.confirm(
__('Cancel this agricultural act on E-Taxes?') + '<br><br>' +
__('Purchase Invoice: {0}', [frm.doc.name]) + '<br>' +
__('E-Taxes Act ID: {0}', [frm.doc.etaxes_purchase_id]) + '<br>' +
__('Status: {0}', [frm.doc.etaxes_purchase_send_status]) + '<br><br>' +
__('<strong>Warning:</strong> This will permanently remove the act from E-Taxes system.'),
function() {
// ✅ CRITICAL: Always wrap E-Taxes API calls with authentication check
ETaxes.auth.checkAndProcess(function() {
// Show loading
frappe.show_alert({
message: __('Cancelling act on E-Taxes...'),
indicator: 'orange'
}, 3);
frappe.call({
method: 'invoice_az.send_purchase_api.cancel_act_on_etaxes',
args: {
purchase_invoice_name: frm.doc.name
},
callback: function(r) {
if (r.message && r.message.success) {
// Success
frappe.show_alert({
message: __('Act successfully removed from E-Taxes'),
indicator: 'green'
}, 5);
// Reload document
frm.reload_doc();
} else {
// Error
const errorMsg = r.message ? r.message.message : __('Failed to cancel act on E-Taxes');
frappe.msgprint({
title: __('Error'),
indicator: 'red',
message: errorMsg
});
}
},
error: function(r) {
frappe.msgprint({
title: __('Error'),
indicator: 'red',
message: __('Failed to communicate with E-Taxes service.')
});
}
});
});
}
);
}
/**
* Show Agricultural Act details dialog
*/
function show_act_details(frm) {
const details = `
<table class="table table-bordered">
<tr>
<th style="width: 40%">${__('Purchase Invoice')}</th>
<td>${frm.doc.name}</td>
</tr>
<tr>
<th>${__('Supplier')}</th>
<td>${frm.doc.supplier_name}</td>
</tr>
<tr>
<th>${__('Act Kind')}</th>
<td>${frm.doc.act_kind || '-'}</td>
</tr>
<tr>
<th>${__('E-Taxes Act ID')}</th>
<td>${frm.doc.etaxes_purchase_id || '-'}</td>
</tr>
<tr>
<th>${__('Serial Number')}</th>
<td><strong>${frm.doc.etaxes_purchase_serial_number || '-'}</strong></td>
</tr>
<tr>
<th>${__('Verification Code')}</th>
<td><strong>${frm.doc.etaxes_purchase_verification_code || '-'}</strong></td>
</tr>
<tr>
<th>${__('Status')}</th>
<td><span class="indicator ${get_status_color(frm.doc.etaxes_purchase_send_status)}">${frm.doc.etaxes_purchase_send_status || '-'}</span></td>
</tr>
</table>
`;
frappe.msgprint({
title: __('E-Taxes Agricultural Act Details'),
message: details,
wide: true
});
}
/**
* Get status indicator color
*/
function get_status_color(status) {
const colors = {
'Sent and Signed': 'green',
'Created, not signed': 'orange',
'Failed': 'red',
'Not Sent': 'gray'
};
return colors[status] || 'gray';
}
frappe.listview_settings['Purchase Invoice'] = {
onload: function(listview) {
// Add field as column (proper way)

View File

@ -0,0 +1,681 @@
// ======= ОБЩИЕ УТИЛИТЫ И КОНСТАНТЫ =======
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(`
<div style="width: 64px; height: 64px; display: flex; align-items: center; justify-content: center;">
<i class="fa fa-check" style="font-size: 48px; color: #5cb85c;"></i>
</div>
`);
$('#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;
// Update dialog title
ETaxes.loadingDialog.set_title(__('Error'));
// Change animation to error icon
ETaxes.loadingDialog.$wrapper.find('.loading-animation').html(`
<div style="width: 64px; height: 64px; display: flex; align-items: center; justify-content: center;">
<i class="fa fa-times" style="font-size: 48px; color: #d9534f;"></i>
</div>
`);
// Clear all existing text elements
ETaxes.loadingDialog.$wrapper.find('#loading_title').text('');
ETaxes.loadingDialog.$wrapper.find('#loading_submessage').remove();
// Set error message
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, '<br>'));
} 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 `
<div class="text-center">
<div class="loading-animation" style="display: inline-block; width: 64px; height: 64px;">
<div style="width: 64px; height: 64px; border: 6px solid #4e73df; border-radius: 50%; border-color: #4e73df transparent #4e73df transparent; animation: lds-dual-ring 1.2s linear infinite;"></div>
</div>
<style>
@keyframes lds-dual-ring {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
<div class="mt-3" id="status_message">
<h4 id="loading_title">${title || __('Please wait...')}</h4>
<p id="loading_message">${message || __('The operation may take some time')}</p>
<p id="loading_submessage" style="${submessage ? '' : 'display:none'}">${submessage || ''}</p>
</div>
<div id="verification_code_container" style="display:none; margin-top: 15px;">
<div style="background: #f8f9fa; padding: 10px; display: inline-block; border-radius: 5px; font-size: 24px; font-weight: bold; letter-spacing: 2px; color: #333; border: 1px solid #ddd;">
<span id="verification_code">----</span>
</div>
<p style="margin-top: 5px; font-size: 12px; color: #666;">Verification Code</p>
</div>
</div>
`;
}
};
// ======= МОДУЛЬ АУТЕНТИФИКАЦИИ =======
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 = '<div style="max-height: 400px; overflow-y: auto;"><table class="table table-bordered">';
certHtml += '<thead><tr><th>Type</th><th>Name</th><th>ID</th><th>Position</th><th>Status</th><th></th></tr></thead><tbody>';
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 += '<tr>' +
'<td>' + (cert.taxpayerType || '') + '</td>' +
'<td>' + name + '</td>' +
'<td>' + id + '</td>' +
'<td>' + (cert.position || '') + '</td>' +
'<td>' + (cert.hasAccess ? 'Active' : 'Inactive') + (cert.liquidated ? ' (Liquidated)' : '') + '</td>' +
'<td><button class="btn btn-xs btn-primary select-cert" data-index="' + index + '">Select</button></td>' +
'</tr>';
});
certHtml += '</tbody></table></div>';
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);
});
}
};
/**
* Supplier Form Configuration
* E-Taxes integration for Individual suppliers
*/
frappe.ui.form.on('Supplier', {
refresh: function(frm) {
// Add event handler for fetch button
if (frm.doc.supplier_type === 'Individual' && frm.doc.fin && frm.doc.passport_serial_number) {
setup_fetch_button_handler(frm);
}
},
// Re-setup button when supplier type changes
supplier_type: function(frm) {
if (frm.doc.supplier_type === 'Individual' && frm.doc.fin && frm.doc.passport_serial_number) {
setup_fetch_button_handler(frm);
}
},
// Re-setup button when FIN changes
fin: function(frm) {
if (frm.doc.supplier_type === 'Individual' && frm.doc.fin && frm.doc.passport_serial_number) {
setup_fetch_button_handler(frm);
}
},
// Re-setup button when Passport changes
passport_serial_number: function(frm) {
if (frm.doc.supplier_type === 'Individual' && frm.doc.fin && frm.doc.passport_serial_number) {
setup_fetch_button_handler(frm);
}
}
});
/**
* Setup click handler for fetch button inside the section
*/
function setup_fetch_button_handler(frm) {
// Wait for DOM to be ready
setTimeout(function() {
$('#fetch-from-etaxes-btn').off('click').on('click', function() {
fetch_supplier_data_from_etaxes(frm);
});
}, 200);
}
/**
* Fetch and auto-fill supplier data from E-Taxes
*/
function fetch_supplier_data_from_etaxes(frm) {
// Check authentication first (same pattern as Sales Invoice)
ETaxes.auth.checkAndProcess(function() {
execute_fetch_supplier_data(frm);
});
}
/**
* Execute the actual fetch operation
*/
function execute_fetch_supplier_data(frm) {
// Show confirmation dialog
frappe.confirm(
__('Fetch supplier data from E-Taxes?') + '<br><br>' +
__('FIN: {0}', [frm.doc.fin]) + '<br>' +
__('Passport: {0}', [frm.doc.passport_serial_number]) + '<br><br>' +
__('This will automatically fill the following fields if they are empty:') + '<br>' +
__('- Date of Birth') + '<br>' +
__('- First Name') + '<br>' +
__('- Last Name') + '<br>' +
__('- Phone Number') + '<br>' +
__('- Supplier Name (Full Name)'),
function() {
// Show loading
frappe.show_alert({
message: __('Fetching data from E-Taxes...'),
indicator: 'blue'
}, 5);
// Make API call
frappe.call({
method: 'invoice_az.supplier_api.update_supplier_from_etaxes',
args: {
supplier_name: frm.doc.name
},
callback: function(r) {
if (r.message && r.message.success) {
// Success
frappe.show_alert({
message: __('Supplier data fetched successfully!') + '<br>' +
r.message.message,
indicator: 'green'
}, 7);
// Reload document to show updated fields
frm.reload_doc();
} else {
// Error
const errorMsg = r.message ? r.message.message : __('Failed to fetch supplier data from E-Taxes');
frappe.msgprint({
title: __('Error'),
indicator: 'red',
message: errorMsg
});
}
},
error: function(r) {
frappe.msgprint({
title: __('Error'),
indicator: 'red',
message: __('Failed to communicate with E-Taxes service.')
});
}
});
}
);
}

View File

@ -10,7 +10,8 @@ doctype_js = {
"Purchase Invoice": "client/purchase_invoice.js",
"Sales Order": "client/sales_order.js",
"Sales Invoice": "client/sales_invoice.js",
"Journal Entry": "client/journal_entry.js"
"Journal Entry": "client/journal_entry.js",
"Supplier": "client/supplier.js"
}
doctype_list_js = {
@ -32,6 +33,10 @@ doc_events = {
"on_trash": "invoice_az.api.on_delete_purchase_order",
"on_cancel": "invoice_az.api.on_delete_purchase_order"
},
"Purchase Invoice": {
"on_trash": "invoice_az.send_purchase_api.on_delete_purchase_invoice",
"on_cancel": "invoice_az.send_purchase_api.on_delete_purchase_invoice"
},
"Sales Order": {
"on_trash": "invoice_az.sales_api.on_delete_sales_order",
"on_cancel": "invoice_az.sales_api.on_delete_sales_order"

View File

@ -0,0 +1,129 @@
{
"actions": [],
"allow_rename": 0,
"autoname": "format:ETPO-{####}",
"creation": "2026-01-27 00:00:00.000000",
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
"purchase_invoice",
"send_date",
"column_break_1",
"status",
"etaxes_section",
"etaxes_act_id",
"serial_number",
"column_break_2",
"verification_code",
"error_section",
"error_message"
],
"fields": [
{
"fieldname": "purchase_invoice",
"fieldtype": "Link",
"in_list_view": 1,
"label": "Purchase Invoice",
"options": "Purchase Invoice",
"reqd": 1,
"unique": 1
},
{
"fieldname": "etaxes_act_id",
"fieldtype": "Data",
"in_list_view": 1,
"label": "E-Taxes Act ID",
"read_only": 1
},
{
"fieldname": "serial_number",
"fieldtype": "Data",
"in_list_view": 1,
"label": "Serial Number",
"read_only": 1
},
{
"fieldname": "verification_code",
"fieldtype": "Data",
"label": "Verification Code",
"read_only": 1
},
{
"default": "Now",
"fieldname": "send_date",
"fieldtype": "Datetime",
"label": "Send Date",
"read_only": 1
},
{
"fieldname": "status",
"fieldtype": "Select",
"in_list_view": 1,
"label": "Status",
"options": "\nSent and Signed\nCreated, not signed\nFailed",
"read_only": 1
},
{
"collapsible": 1,
"fieldname": "error_section",
"fieldtype": "Section Break",
"label": "Error Details"
},
{
"fieldname": "error_message",
"fieldtype": "Text",
"label": "Error Message",
"read_only": 1
},
{
"fieldname": "column_break_1",
"fieldtype": "Column Break"
},
{
"fieldname": "etaxes_section",
"fieldtype": "Section Break",
"label": "E-Taxes Details"
},
{
"fieldname": "column_break_2",
"fieldtype": "Column Break"
}
],
"index_web_pages_for_search": 1,
"links": [],
"modified": "2026-01-27 00:00:00.000000",
"modified_by": "Administrator",
"module": "Invoice Az",
"name": "E-Taxes Purchase Outbox",
"naming_rule": "Expression",
"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
}

View File

@ -0,0 +1,9 @@
# Copyright (c) 2026, Jey ERP and contributors
# For license information, please see license.txt
import frappe
from frappe.model.document import Document
class ETaxesPurchaseOutbox(Document):
pass

File diff suppressed because it is too large Load Diff

View File

@ -68,7 +68,6 @@ def send_sales_invoice_to_etaxes(sales_invoice_name):
token = asan_login.get("main_token")
# Step 1: Generate serial number
frappe.logger().info(f"[SEND_SALES] Generating serial number for {sales_invoice_name}")
serial_result = generate_serial_number(token)
if not serial_result.get("success"):
return {
@ -77,14 +76,11 @@ def send_sales_invoice_to_etaxes(sales_invoice_name):
}
serial_number = serial_result.get("serial_number")
frappe.logger().info(f"[SEND_SALES] Serial number generated: {serial_number}")
# Step 2: Build invoice payload
frappe.logger().info(f"[SEND_SALES] Building payload for {sales_invoice_name}")
payload = build_invoice_payload(doc, serial_number)
# Step 3: Create invoice on E-Taxes
frappe.logger().info(f"[SEND_SALES] Creating invoice on E-Taxes")
create_result = create_invoice_on_etaxes(token, payload, serial_number)
if not create_result.get("success"):
return {
@ -93,15 +89,12 @@ def send_sales_invoice_to_etaxes(sales_invoice_name):
}
invoice_id = create_result.get("invoice_id")
frappe.logger().info(f"[SEND_SALES] Invoice created with ID: {invoice_id}")
# Step 4: Set status as draft (signing will be done separately via "Sign Document" button)
verification_code = None
final_status = "Created, not signed"
frappe.logger().info(f"[SEND_SALES] Draft invoice created successfully. User will need to sign separately.")
# Step 5: Create tracking record
frappe.logger().info(f"[SEND_SALES] Creating E-Taxes Sales Outbox record")
create_outbox_record(
sales_invoice_name,
invoice_id,
@ -112,7 +105,6 @@ def send_sales_invoice_to_etaxes(sales_invoice_name):
)
# Step 6: Update Sales Invoice
frappe.logger().info(f"[SEND_SALES] Updating Sales Invoice {sales_invoice_name}")
# Use db.set_value to avoid triggering validation
frappe.db.set_value("Sales Invoice", sales_invoice_name, {
@ -398,8 +390,6 @@ def get_customer_objects(sales_invoice_name):
"mainObjectOnly": "true"
}
frappe.logger().info(f"[GET_CUSTOMER_OBJECTS] Fetching objects for customer {doc.customer} (TIN: {tax_id})")
# Make API request
response = requests.get(url, headers=headers, params=params, timeout=30)
@ -419,7 +409,6 @@ def get_customer_objects(sales_invoice_name):
# Handle 404 Not Found (customer not found or no objects)
if response.status_code == 404:
frappe.logger().info(f"[GET_CUSTOMER_OBJECTS] No objects found for TIN: {tax_id}")
return {
"success": True,
"objects": [],
@ -435,13 +424,9 @@ def get_customer_objects(sales_invoice_name):
result = response.json()
objects = result.get("objects", [])
frappe.logger().info(f"[GET_CUSTOMER_OBJECTS] Found {len(objects)} objects for TIN: {tax_id}")
# Filter only active objects (status = "A")
active_objects = [obj for obj in objects if obj.get("status") == "A"]
frappe.logger().info(f"[GET_CUSTOMER_OBJECTS] {len(active_objects)} active objects for TIN: {tax_id}")
return {
"success": True,
"objects": active_objects,
@ -580,8 +565,6 @@ def create_invoice_on_etaxes(token, payload, serial_number):
headers = DEFAULT_HEADERS.copy()
headers["x-authorization"] = f"Bearer {token}"
frappe.logger().info(f"[CREATE_INVOICE] Sending payload for serial: {serial_number}")
response = requests.post(
INVOICE_URL,
data=json.dumps(payload),
@ -601,6 +584,30 @@ def create_invoice_on_etaxes(token, payload, serial_number):
"message": "E-Taxes service error. Please try again later."
}
# Handle errors with E-Taxes message format
if response.status_code != 200:
try:
error_data = response.json()
# Try to get Azerbaijani message first
if isinstance(error_data, dict) and "message" in error_data:
if isinstance(error_data["message"], dict) and "az" in error_data["message"]:
return {
"success": False,
"message": error_data["message"]["az"]
}
elif isinstance(error_data["message"], str):
return {
"success": False,
"message": error_data["message"]
}
except:
pass
return {
"success": False,
"message": f"E-Taxes error (status {response.status_code})"
}
response.raise_for_status()
result = response.json()
@ -612,8 +619,6 @@ def create_invoice_on_etaxes(token, payload, serial_number):
"message": "No invoice ID returned from E-Taxes"
}
frappe.logger().info(f"[CREATE_INVOICE] Created invoice ID: {invoice_id}")
return {
"success": True,
"invoice_id": invoice_id
@ -905,8 +910,6 @@ def cancel_invoice_on_etaxes(sales_invoice_name):
# Build payload - array with invoice ID (API expects array of IDs)
payload = [invoice_id]
frappe.logger().info(f"[CANCEL_INVOICE] Removing draft invoice {invoice_id} from E-Taxes")
# Make DELETE request
response = requests.delete(
REMOVE_DRAFT_URL,
@ -918,8 +921,6 @@ def cancel_invoice_on_etaxes(sales_invoice_name):
# Check response
if response.status_code == 204:
# Success - invoice removed
frappe.logger().info(f"[CANCEL_INVOICE] Successfully removed invoice {invoice_id} from E-Taxes")
# Clear E-Taxes fields in Sales Invoice
frappe.db.set_value("Sales Invoice", sales_invoice_name, {
"etaxes_invoice_id": None,
@ -953,8 +954,6 @@ def cancel_invoice_on_etaxes(sales_invoice_name):
elif response.status_code == 404:
# Invoice not found on E-Taxes - might be already deleted
frappe.logger().info(f"[CANCEL_INVOICE] Invoice {invoice_id} not found on E-Taxes (404)")
# Clear fields anyway
frappe.db.set_value("Sales Invoice", sales_invoice_name, {
"etaxes_invoice_id": None,
@ -971,17 +970,18 @@ def cancel_invoice_on_etaxes(sales_invoice_name):
}
else:
# Other error
# Other error - try to get Azerbaijani message
error_msg = f"E-Taxes returned status {response.status_code}"
try:
error_data = response.json()
if error_data.get("message"):
error_msg = error_data.get("message")
if isinstance(error_data, dict) and "message" in error_data:
if isinstance(error_data["message"], dict) and "az" in error_data["message"]:
error_msg = error_data["message"]["az"]
elif isinstance(error_data["message"], str):
error_msg = error_data["message"]
except:
pass
frappe.logger().error(f"[CANCEL_INVOICE] Failed to remove invoice: {error_msg}")
return {
"success": False,
"message": f"Failed to remove invoice from E-Taxes: {error_msg}"
@ -1015,11 +1015,8 @@ def on_cancel_sales_invoice(doc, method):
try:
# Check if invoice was sent to E-Taxes
if doc.etaxes_invoice_id and doc.etaxes_send_status in ["Created, not signed", "Sent and Signed"]:
# Log that invoice needs to be cancelled on E-Taxes
frappe.logger().info(
f"Sales Invoice {doc.name} cancelled. E-Taxes Invoice ID: {doc.etaxes_invoice_id}. "
f"User should manually cancel on E-Taxes using the 'Cancel on E-Taxes' button."
)
# User should manually cancel on E-Taxes using the 'Cancel on E-Taxes' button
pass
except Exception as e:
frappe.log_error(
f"Error in on_cancel_sales_invoice hook: {str(e)}\n{frappe.get_traceback()}",
@ -1030,8 +1027,6 @@ def on_cancel_sales_invoice(doc, method):
def on_delete_sales_invoice(doc, method):
"""Deletes related E-Taxes Sales Outbox record when Sales Invoice is deleted or cancelled"""
try:
frappe.logger().info(f"Checking for E-Taxes Sales Outbox records for Sales Invoice {doc.name}")
# Find all E-Taxes Sales Outbox records linked to this Sales Invoice
outbox_records = frappe.get_all(
"E-Taxes Sales Outbox",
@ -1041,14 +1036,9 @@ def on_delete_sales_invoice(doc, method):
if outbox_records:
for record in outbox_records:
frappe.logger().info(f"Deleting E-Taxes Sales Outbox {record.name} due to Sales Invoice {doc.name} deletion/cancellation")
frappe.delete_doc('E-Taxes Sales Outbox', record.name, ignore_permissions=True, force=True)
frappe.logger().info(f"Successfully deleted E-Taxes Sales Outbox {record.name}")
frappe.db.commit()
frappe.logger().info(f"Deleted {len(outbox_records)} E-Taxes Sales Outbox record(s) for Sales Invoice {doc.name}")
else:
frappe.logger().info(f"No E-Taxes Sales Outbox records found for Sales Invoice {doc.name}")
except Exception as e:
frappe.log_error(

242
invoice_az/supplier_api.py Normal file
View File

@ -0,0 +1,242 @@
import frappe
import requests
import json
from invoice_az.auth import record_etaxes_activity, get_default_asan_login
# Constants
BASE_URL = "https://new.e-taxes.gov.az"
FIND_BY_FIN_URL = f"{BASE_URL}/api/po/profile/public/v1/taxpayer/findByFinAndPassport"
DEFAULT_HEADERS = {
"Accept": "application/json, text/plain, */*",
"Accept-Language": "en-US,en;q=0.9",
"Content-Type": "application/json",
"Cache-Control": "no-cache",
"Pragma": "no-cache"
}
@frappe.whitelist()
def fetch_supplier_data_from_etaxes(supplier_name):
"""
Fetch Individual supplier data from E-Taxes by FIN and Passport Serial Number
Args:
supplier_name: Name of Supplier document
Returns:
dict: {
"success": bool,
"data": {
"fin": str,
"fullName": str,
"phone": str,
"dateOfBirth": str,
"firstname": str,
"lastname": str,
"patronimyc": str
},
"message": str
}
"""
try:
# Record activity for token renewal
record_etaxes_activity()
# Get Supplier document
supplier = frappe.get_doc("Supplier", supplier_name)
# Validate supplier type
if supplier.supplier_type != "Individual":
return {
"success": False,
"message": "This function only works for Individual suppliers"
}
# Check required fields
if not supplier.fin:
return {
"success": False,
"message": "FIN is required. Please fill FIN field first."
}
if not supplier.passport_serial_number:
return {
"success": False,
"message": "Passport Serial Number is required. Please fill Passport Serial Number field first."
}
# Get authentication token
asan_login = get_default_asan_login()
if not asan_login.get("found") or not asan_login.get("main_token"):
return {
"success": False,
"message": "E-Taxes authentication not found. Please login via ASAN."
}
token = asan_login.get("main_token")
# Build headers
headers = DEFAULT_HEADERS.copy()
headers["x-authorization"] = f"Bearer {token}"
# Build payload
payload = {
"fin": supplier.fin,
"passportSerialNumber": supplier.passport_serial_number
}
# Make API request
response = requests.post(
FIND_BY_FIN_URL,
data=json.dumps(payload),
headers=headers,
timeout=30
)
# Handle 401 Unauthorized
if response.status_code == 401:
return {
"success": False,
"message": "Authentication expired. Please login again via ASAN."
}
# Handle 500 Server Error
if response.status_code == 500:
return {
"success": False,
"message": "E-Taxes service error. Please try again later."
}
# Handle 404 Not Found (person not found)
if response.status_code == 404:
return {
"success": False,
"message": "Person not found in E-Taxes system. Please check FIN and Passport Serial Number."
}
# Handle errors with E-Taxes message format
if response.status_code != 200:
try:
error_data = response.json()
# Try to get Azerbaijani message first
if isinstance(error_data, dict) and "message" in error_data:
if isinstance(error_data["message"], dict) and "az" in error_data["message"]:
return {
"success": False,
"message": error_data["message"]["az"]
}
elif isinstance(error_data["message"], str):
return {
"success": False,
"message": error_data["message"]
}
except:
pass
return {
"success": False,
"message": f"E-Taxes error (status {response.status_code})"
}
response.raise_for_status()
# Parse response
result = response.json()
return {
"success": True,
"data": result
}
except requests.exceptions.RequestException as e:
frappe.log_error(
f"Error fetching supplier data from E-Taxes: {str(e)}",
"Fetch Supplier Data Error"
)
return {
"success": False,
"message": f"Network error: {str(e)}"
}
except Exception as e:
frappe.log_error(
f"Unexpected error fetching supplier data: {str(e)}\n{frappe.get_traceback()}",
"Fetch Supplier Data Error"
)
return {
"success": False,
"message": f"Error: {str(e)}"
}
@frappe.whitelist()
def update_supplier_from_etaxes(supplier_name):
"""
Fetch data from E-Taxes and update Supplier fields
Args:
supplier_name: Name of Supplier document
Returns:
dict: Success/error response with update details
"""
try:
# Fetch data from E-Taxes
fetch_result = fetch_supplier_data_from_etaxes(supplier_name)
if not fetch_result.get("success"):
return fetch_result
data = fetch_result.get("data")
# Get Supplier document
supplier = frappe.get_doc("Supplier", supplier_name)
# Update fields from E-Taxes response
updated_fields = []
# Date of birth
if data.get("dateOfBirth") and not supplier.date_of_birth:
supplier.date_of_birth = data.get("dateOfBirth")
updated_fields.append("Date of Birth")
# First name
if data.get("firstname"):
supplier.first_name_individual = data.get("firstname")
updated_fields.append("First Name")
# Last name
if data.get("lastname"):
supplier.last_name_individual = data.get("lastname")
updated_fields.append("Last Name")
# Phone number (optional)
if data.get("phone") and not supplier.phone_number_individual:
supplier.phone_number_individual = data.get("phone")
updated_fields.append("Phone Number")
# Update supplier name if needed (use fullName from E-Taxes)
if data.get("fullName") and supplier.supplier_name != data.get("fullName"):
old_name = supplier.supplier_name
supplier.supplier_name = data.get("fullName")
updated_fields.append(f"Supplier Name (from '{old_name}' to '{data.get('fullName')}')")
# Save supplier
supplier.save()
frappe.db.commit()
return {
"success": True,
"message": f"Supplier data updated successfully. Updated fields: {', '.join(updated_fields)}",
"updated_fields": updated_fields
}
except Exception as e:
frappe.log_error(
f"Error updating supplier from E-Taxes: {str(e)}\n{frappe.get_traceback()}",
"Update Supplier Error"
)
return {
"success": False,
"message": f"Error: {str(e)}"
}