Счёт и ставка налога для позиции в счёте выбираются автоматически в зависимости от типа продажи:
Пользователь ничего не выбирает вручную — система сама подставляет правильные настройки.
Ставка НДС для клиента остаётся такой же, как при продаже внутри Эстонии
4145Доходный счёт назначается → (так бухучёт видит Tax-Free отдельно)
Если менять Tax Category со EU/Export обратно на Eesti:
Если изменили категорию на EU или Export:
# ============================================================================
# Sales Invoice – Automatic Income Account Assignment
# ============================================================================
# Version: 3.2 (Production)
# Maintainer: ravolar
# Date: 2025-12-03
# Trigger: Before Save
# DocType: Sales Invoice
#
# Purpose:
# Automatically assigns the correct income account and tax template
# based on:
# – Tax Category (Eesti, EU, Export, Tax-Free)
# – Item Group (books 9%, maksuvaba 0%, erikorra 0%)
#
# Covers all transition cases:
# Eesti → EU → Tax-Free → Eesti
# EU → Export → Eesti
# Export/EU → Tax-Free
# ============================================================================
# ============================================================================
# CONFIGURATION
# ============================================================================
# Income account numbers (same logic for all companies)
INCOME_ACCOUNT_NUMBERS = {
'default': '4110', # Standard 24%
'books_9': '4115', # Books 9%
'maksuvaba_0': '4130', # VAT-exempt (Maksuvaba)
'erikorra': '4150', # Special scheme §411 (0%)
'eu_goods': '4120', # EU goods (0% RC)
'eu_services': '4125', # EU services (0% RC)
'export': '4140', # Export 0%
'tax_free': '4145' # Tax-Free (travellers)
}
# Tax Categories treated as domestic Estonian sales
ESTONIAN_TAX_CATEGORIES = ('', 'Eesti')
# Foreign income account numbers (used to detect transitions)
FOREIGN_ACCOUNT_NUMBERS = ('4120', '4125', '4140', '4145')
# Item Groups that have special VAT rules (9%, 0%, §411)
GROUP_RULES = {
'Raamatud': 'books_9', # Books → 9%
'Maksuvaba': 'maksuvaba_0', # Maksuvaba → 0%
'Erikorra müük': 'erikorra', # §411 special scheme → 0%
}
# ============================================================================
# HELPER: Restore Item Tax Template based on Item Group defaults
# ============================================================================
def restore_item_tax_template(item, item_group, company):
"""
Restores the correct Item Tax Template for this item based on
Item Group defaults.
Used when switching:
– from EU/Export back to Eesti
– to Tax-Free (travellers must see VAT on receipt)
Returns:
True → template restored
False → no template restored or nothing to change
"""
# Nothing to restore if no group or template already exists
if not item_group or item.item_tax_template:
return False
try:
# Load Item Group and its tax settings
item_group_doc = frappe.get_doc('Item Group', item_group)
taxes_list = item_group_doc.get('taxes')
if not taxes_list:
return False
# Find Item Tax Template that belongs to this company
for item_tax_row in taxes_list:
template_name = item_tax_row.item_tax_template
template_doc = frappe.get_doc('Item Tax Template', template_name)
if template_doc.company == company:
# Apply template
item.item_tax_template = template_name
# Build JSON manually (safe_exec does not allow json.dumps)
tax_rate_pairs = []
template_taxes = template_doc.get('taxes')
if template_taxes:
for tax_row in template_taxes:
account = tax_row.tax_type
rate = tax_row.tax_rate
account_escaped = str(account).replace('"', '\\"')
tax_rate_pairs.append(f'"{account_escaped}":{rate}')
# Apply JSON string for Frappe to parse
item.item_tax_rate = (
'{' + ','.join(tax_rate_pairs) + '}' if tax_rate_pairs else '{}'
)
return True
return False
except Exception as e:
frappe.log_error(
f"Error restoring tax template: {str(e)}",
"Sales Invoice Tax"
)
return False
# ============================================================================
# MAIN LOGIC
# ============================================================================
if doc.items and doc.company:
tax_category = (doc.tax_category or '').strip()
need_tax_recalc = False
# ------------------------------------------------------------------------
# Cache income accounts (performance: 1 lookup per account per company)
# ------------------------------------------------------------------------
account_cache = {}
for key, account_num in INCOME_ACCOUNT_NUMBERS.items():
account_name = frappe.db.get_value(
'Account',
{'account_number': account_num, 'company': doc.company},
'name'
)
if account_name:
account_cache[key] = account_name
# ------------------------------------------------------------------------
# Process all invoice items
# ------------------------------------------------------------------------
for item in doc.items:
if not item.item_code:
continue
income_account = None
item_group = (item.item_group or '').strip()
# ====================================================================
# 1. FOREIGN SALES LOGIC (EU, Export, Tax-Free)
# ====================================================================
if tax_category in ('Export', 'EU', 'Tax-Free'):
# --- Export: 0% and NO VAT shown ---
if tax_category == 'Export':
income_account = account_cache.get('export')
if item.item_tax_template:
item.item_tax_template = None
item.item_tax_rate = '{}'
need_tax_recalc = True
# --- EU: 0% reverse charge, NO VAT shown ---
elif tax_category == 'EU':
if item_group == 'Teenused':
income_account = account_cache.get('eu_services')
else:
income_account = account_cache.get('eu_goods')
if item.item_tax_template:
item.item_tax_template = None
item.item_tax_rate = '{}'
need_tax_recalc = True
# --- Tax-Free: VAT must appear on customer receipt ---
elif tax_category == 'Tax-Free':
income_account = account_cache.get('tax_free')
# Restore VAT templates for special Item Groups (books 9%, etc.)
if item_group in GROUP_RULES:
if restore_item_tax_template(item, item_group, doc.company):
need_tax_recalc = True
# ====================================================================
# 2. ESTONIAN SALES LOGIC
# ====================================================================
elif tax_category in ESTONIAN_TAX_CATEGORIES:
current_account = item.income_account or ''
current_acc_num = current_account.split(' - ')[0].strip()
is_foreign = current_acc_num in FOREIGN_ACCOUNT_NUMBERS
# Only fix items that were previously EU/Export/Tax-Free
if is_foreign:
if item_group in GROUP_RULES:
# Apply correct domestic income account (9%, 0%, etc.)
account_key = GROUP_RULES[item_group]
income_account = account_cache.get(account_key)
# Restore VAT template for the item
if restore_item_tax_template(item, item_group, doc.company):
need_tax_recalc = True
else:
# Regular items → standard 24% account
income_account = account_cache.get('default')
# ====================================================================
# 3. APPLY INCOME ACCOUNT (only if changed)
# ====================================================================
if income_account and income_account != item.income_account:
item.income_account = income_account
need_tax_recalc = True
# ------------------------------------------------------------------------
# 4. FINAL TAX RECALCULATION (only if needed)
# ------------------------------------------------------------------------
if need_tax_recalc:
doc.calculate_taxes_and_totals()
Скрипт автоматически назначает корректный доходный счёт и корректный Item Tax Template в зависимости от Tax Category и Item Group, включая все переходы между ними.
{}income_account:
item_tax_template → None
item_tax_rate → {}
всегда 0% (нет KM).
Если текущий item.income_account в FOREIGN_ACCOUNT_NUMBERS (4120/4125/4140/4145) → вернуть эстонский счёт:
Если item_tax_template пустой → восстановить из Item Group (как в Tax-Free).
item_tax_rate восстанавливается вручную.
restore_item_tax_template(item, group, company):
{"Account":rate,...}Счета (Account.name) запрашиваются один раз по номеру счёта (account_number) → сохраняются в account_cache.
Флаг need_tax_recalc = True устанавливается при любом изменении VAT или income_account.
В конце вызывается doc.calculate_taxes_and_totals().