815 lines
38 KiB
Python
815 lines
38 KiB
Python
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import base64
|
|
import re
|
|
import uuid
|
|
|
|
from urllib.parse import urljoin
|
|
|
|
from odoo import api, fields, models
|
|
from odoo.addons.l10n_tw_edi_ecpay.utils import call_ecpay_api, transfer_time
|
|
from odoo.exceptions import UserError
|
|
from odoo.tools import float_round
|
|
|
|
|
|
class AccountMove(models.Model):
|
|
_inherit = "account.move"
|
|
|
|
# ------------------
|
|
# Fields declaration
|
|
# ------------------
|
|
|
|
l10n_tw_edi_file_id = fields.Many2one(
|
|
comodel_name="ir.attachment",
|
|
compute=lambda self: self._compute_linked_attachment_id("l10n_tw_edi_file_id", "l10n_tw_edi_file"),
|
|
depends=["l10n_tw_edi_file"],
|
|
copy=False,
|
|
export_string_translation=False,
|
|
)
|
|
l10n_tw_edi_file = fields.Binary(
|
|
string="Ecpay JSON File",
|
|
copy=False,
|
|
readonly=True,
|
|
export_string_translation=False,
|
|
)
|
|
l10n_tw_edi_ecpay_invoice_id = fields.Char(string="Ecpay Invoice Number", readonly=True, copy=False)
|
|
l10n_tw_edi_related_number = fields.Char(
|
|
string="Related Number",
|
|
copy=False,
|
|
readonly=True,
|
|
store=True,
|
|
)
|
|
# False => Not sent yet.
|
|
l10n_tw_edi_state = fields.Selection(
|
|
string="Invoice Status",
|
|
selection=[
|
|
("invoiced", "Invoiced"),
|
|
("valid", "Valid"),
|
|
("invalid", "Invalid"),
|
|
],
|
|
copy=False,
|
|
readonly=True,
|
|
tracking=True,
|
|
)
|
|
l10n_tw_edi_love_code = fields.Char(string="Love Code", compute="_compute_love_code", store=True, readonly=False)
|
|
l10n_tw_edi_is_print = fields.Boolean(
|
|
string="Get Printed Version",
|
|
compute="_compute_is_print",
|
|
store=True,
|
|
readonly=False,
|
|
)
|
|
l10n_tw_edi_carrier_type = fields.Selection(
|
|
string="Carrier Type",
|
|
selection=[
|
|
("1", "ECpay e-invoice carrier"),
|
|
("2", "Citizen Digital Certificate"),
|
|
("3", "Mobile Barcode"),
|
|
("4", "EasyCard"),
|
|
("5", "iPass"),
|
|
],
|
|
copy=False,
|
|
readonly=False,
|
|
compute="_compute_carrier_info",
|
|
store=True,
|
|
help="""
|
|
- Citizen Digital Certificate: The carrier number format is 2 capital letters following 14 digits.
|
|
- Mobile Barcode: The carrier number format is / following 7 alphanumeric or +-. string.
|
|
- EasyCard or iPass: The carrier number is the card hidden code, the carrier number 2 is the card visible code.
|
|
"""
|
|
)
|
|
l10n_tw_edi_carrier_number = fields.Char(
|
|
string="Carrier Number",
|
|
compute="_compute_carrier_info",
|
|
store=True,
|
|
readonly=False,
|
|
copy=False,
|
|
)
|
|
l10n_tw_edi_carrier_number_2 = fields.Char(
|
|
string="Carrier Number 2",
|
|
compute="_compute_carrier_info",
|
|
store=True,
|
|
readonly=False,
|
|
copy=False,
|
|
)
|
|
l10n_tw_edi_invoice_type = fields.Selection(
|
|
string="Ecpay Invoice Type",
|
|
selection=[
|
|
("07", "General Invoice"),
|
|
("08", "Special Invoice"),
|
|
],
|
|
compute="_compute_l10n_tw_edi_invoice_type",
|
|
store=True,
|
|
readonly=False,
|
|
copy=False,
|
|
)
|
|
l10n_tw_edi_clearance_mark = fields.Selection(
|
|
string="Clearance Mark",
|
|
selection=[
|
|
("1", "NOT via the customs"),
|
|
("2", "Via the customs"),
|
|
],
|
|
copy=False,
|
|
)
|
|
l10n_tw_edi_zero_tax_rate_reason = fields.Selection(
|
|
string="Zero Tax Rate Reason",
|
|
selection=[
|
|
("71", "71: No.1 export goods"),
|
|
("72", "72: No.2 Services related to export sales, or services provided domestically but used abroad"),
|
|
("73", "73: No.3 Duty-free shops established by law for the sale and transit or departure of passengers"),
|
|
("74", "74: No.4 Sale of goods or services for operation by the operator of the FREE Trade Zone"),
|
|
("75", "75: No.5 International transportation. However, foreign transport undertakings operating "
|
|
"international transport business in Taiwan shall be limited to those whose countries shall give equal "
|
|
"treatment to Taiwan's international transport undertakings or be exempt from similar taxes"),
|
|
("76", "76: No.6 Ships, aircraft and distant-water fishing vessels for international transportation"),
|
|
("77", "77: No.7 Goods or repair services used by ships, aircraft and distant-water fishing vessels for "
|
|
"sale and international transport"),
|
|
("78", "78: No.8 The bonded area operator sells goods that are not directly exported by the taxable "
|
|
"area operator and the taxable area operator is not exported to the taxation area"),
|
|
("79", "79: No.9 The bonded area operator sells the goods that the taxable area operator deposits into the "
|
|
"bonded warehouse or logistics center managed by the free port area or customs administration for export"),
|
|
],
|
|
copy=False,
|
|
)
|
|
l10n_tw_edi_is_zero_tax_rate = fields.Boolean(
|
|
string="Is Zero Tax Rate",
|
|
compute="_compute_l10n_tw_edi_is_zero_tax_rate",
|
|
copy=False,
|
|
)
|
|
l10n_tw_edi_invoice_create_date = fields.Datetime(string="Creation Date", readonly=True, copy=False)
|
|
l10n_tw_edi_refund_state = fields.Selection(
|
|
string="Refund State",
|
|
selection=[
|
|
("to_be_agreed", "To be agreed"),
|
|
("agreed", "Agreed"),
|
|
("disagreed", "Disagreed"),
|
|
],
|
|
readonly=True,
|
|
copy=False,
|
|
)
|
|
l10n_tw_edi_refund_agreement_type = fields.Selection(
|
|
string="Refund invoice Agreement Type",
|
|
selection=[
|
|
("offline", "Offline Agreement"),
|
|
("online", "Online Agreement"),
|
|
],
|
|
copy=False,
|
|
)
|
|
l10n_tw_edi_allowance_notify_way = fields.Selection(
|
|
string="Allowance Notify Way",
|
|
selection=[
|
|
("email", "Email"),
|
|
("phone", "Phone"),
|
|
],
|
|
copy=False,
|
|
)
|
|
l10n_tw_edi_invalidate_reason = fields.Char(string="Invalidate Reason", readonly=True, copy=False)
|
|
l10n_tw_edi_refund_invoice_number = fields.Char(string="Refund Invoice Number", readonly=True, copy=False)
|
|
l10n_tw_edi_is_b2b = fields.Boolean(string="Is B2B", compute="_compute_l10n_tw_edi_is_b2b")
|
|
|
|
@api.depends("l10n_tw_edi_state")
|
|
def _compute_need_cancel_request(self):
|
|
# EXTENDS 'account'
|
|
super()._compute_need_cancel_request()
|
|
|
|
@api.depends("l10n_tw_edi_state", "l10n_tw_edi_refund_state")
|
|
def _compute_show_reset_to_draft_button(self):
|
|
# EXTEND 'account'
|
|
super()._compute_show_reset_to_draft_button()
|
|
if self.move_type == "out_invoice":
|
|
self.filtered(lambda m: m.l10n_tw_edi_state and m.l10n_tw_edi_state != "invalid").show_reset_to_draft_button = False
|
|
elif self.move_type == "out_refund":
|
|
self.filtered(lambda m: m.l10n_tw_edi_refund_state).show_reset_to_draft_button = False
|
|
|
|
def _need_cancel_request(self):
|
|
# EXTENDS 'account'
|
|
return super()._need_cancel_request() or self.l10n_tw_edi_state in ["invoiced", "valid"]
|
|
|
|
def button_request_cancel(self):
|
|
# EXTENDS 'account'
|
|
if self._need_cancel_request() and self.l10n_tw_edi_state in ["invoiced", "valid"]:
|
|
return {
|
|
"name": self.env._("Cancel Ecpay Invoice"),
|
|
"type": "ir.actions.act_window",
|
|
"view_type": "form",
|
|
"view_mode": "form",
|
|
"res_model": "l10n_tw_edi.invoice.cancel",
|
|
"target": "new",
|
|
"context": {
|
|
"default_invoice_id": self.id,
|
|
},
|
|
}
|
|
return super().button_request_cancel()
|
|
|
|
def button_draft(self):
|
|
# EXTENDS 'account'
|
|
invoices_to_reset = self.filtered(
|
|
lambda i: (i.state == "cancel" and i.l10n_tw_edi_state == "invalid")
|
|
)
|
|
res = super().button_draft()
|
|
|
|
invoices_to_reset.write({
|
|
"l10n_tw_edi_related_number": False,
|
|
"l10n_tw_edi_state": False,
|
|
"l10n_tw_edi_ecpay_invoice_id": False,
|
|
"l10n_tw_edi_invoice_create_date": False,
|
|
"l10n_tw_edi_invalidate_reason": False,
|
|
})
|
|
invoices_to_reset.l10n_tw_edi_file_id.unlink()
|
|
return res
|
|
|
|
@api.depends("l10n_tw_edi_love_code", "l10n_tw_edi_carrier_type", "partner_id")
|
|
def _compute_is_print(self):
|
|
for move in self:
|
|
if move.l10n_tw_edi_love_code or (move.partner_id.vat and move.l10n_tw_edi_carrier_type in [1, 2]):
|
|
move.l10n_tw_edi_is_print = False
|
|
|
|
@api.depends("l10n_tw_edi_is_print", "l10n_tw_edi_carrier_type", "partner_id")
|
|
def _compute_love_code(self):
|
|
for move in self:
|
|
if move.l10n_tw_edi_is_print or move.l10n_tw_edi_carrier_type or move.partner_id.vat:
|
|
move.l10n_tw_edi_love_code = False
|
|
|
|
@api.depends("l10n_tw_edi_is_print", "l10n_tw_edi_love_code")
|
|
def _compute_carrier_info(self):
|
|
for move in self:
|
|
if move.l10n_tw_edi_is_print or move.l10n_tw_edi_love_code:
|
|
move.l10n_tw_edi_carrier_type = False
|
|
move.l10n_tw_edi_carrier_number = False
|
|
move.l10n_tw_edi_carrier_number_2 = False
|
|
|
|
@api.depends("invoice_line_ids.tax_ids")
|
|
def _compute_l10n_tw_edi_invoice_type(self):
|
|
for move in self:
|
|
tax_type, special_tax_type, _ = move._l10n_tw_edi_determine_tax_types()
|
|
if tax_type == "3":
|
|
move.l10n_tw_edi_invoice_type = "07" if not special_tax_type else "08"
|
|
else:
|
|
move.l10n_tw_edi_invoice_type = "07" if tax_type != "4" else "08"
|
|
|
|
@api.depends("invoice_line_ids.tax_ids")
|
|
def _compute_l10n_tw_edi_is_zero_tax_rate(self):
|
|
for move in self:
|
|
_, _, is_zero_tax_rate = move._l10n_tw_edi_determine_tax_types()
|
|
move.l10n_tw_edi_is_zero_tax_rate = is_zero_tax_rate if move.invoice_line_ids.tax_ids else False
|
|
|
|
@api.depends("partner_id")
|
|
def _compute_l10n_tw_edi_is_b2b(self):
|
|
for rec in self:
|
|
rec.l10n_tw_edi_is_b2b = rec.partner_id.commercial_partner_id.is_company
|
|
|
|
# ----------------
|
|
# Business methods
|
|
# ----------------
|
|
def _get_mail_thread_data_attachments(self):
|
|
res = super()._get_mail_thread_data_attachments()
|
|
return res | self.l10n_tw_edi_file_id
|
|
|
|
# API methods
|
|
def _l10n_tw_edi_check_tax_type_on_invoice_lines(self):
|
|
"""
|
|
Check the tax type and special tax type on the invoice lines
|
|
"""
|
|
self.ensure_one()
|
|
product_lines = self.invoice_line_ids.filtered(lambda line: line.display_type == "product")
|
|
errors = []
|
|
# Invoice lines without tax or having multiple taxes are not allowed
|
|
if product_lines.filtered(lambda line: not line.tax_ids or len(line.tax_ids) > 1):
|
|
errors.append(self.env._("Invoice lines without taxes or more than one tax are not allowed."))
|
|
|
|
if len(set(product_lines.tax_ids.mapped('price_include'))) > 1:
|
|
errors.append(self.env._("Invoice lines with different tax include/exclude are not allowed."))
|
|
|
|
# Create a set of tax types on invoice lines to check if there are multiple tax types or specific tax type on the invoice lines
|
|
invoice_lines_tax_types = set(product_lines.tax_ids.mapped('l10n_tw_edi_tax_type'))
|
|
|
|
if invoice_lines_tax_types:
|
|
# Tax type "4" is a special tax rate, cannot be mixed with other tax types
|
|
if "4" in invoice_lines_tax_types and len(invoice_lines_tax_types) > 1:
|
|
errors.append(self.env._(
|
|
"Special tax type cannot be mixed with other tax types."
|
|
))
|
|
|
|
special_tax_types_4 = set(product_lines.tax_ids.filtered(
|
|
lambda t: t.l10n_tw_edi_tax_type == '4'
|
|
).mapped('l10n_tw_edi_special_tax_type'))
|
|
|
|
if "4" in invoice_lines_tax_types and len(special_tax_types_4) > 1:
|
|
errors.append(self.env._(
|
|
"Special tax type cannot be mixed with other special tax types."
|
|
))
|
|
|
|
special_tax_types_3 = set(product_lines.tax_ids.filtered(
|
|
lambda t: t.l10n_tw_edi_tax_type == '3'
|
|
).mapped('l10n_tw_edi_special_tax_type'))
|
|
|
|
if "3" in invoice_lines_tax_types and len(special_tax_types_3) > 1:
|
|
errors.append(self.env._(
|
|
"Duty free with special tax type cannot be mixed with duty free without special tax type or having more than one special tax type."
|
|
))
|
|
|
|
if "3" in invoice_lines_tax_types and next(iter(special_tax_types_3)) == "8" and len(invoice_lines_tax_types) > 1:
|
|
errors.append(self.env._(
|
|
"Duty free with special tax type cannot be mixed with other tax types."
|
|
))
|
|
|
|
if {"2", "3"}.issubset(invoice_lines_tax_types):
|
|
errors.append(self.env._(
|
|
"Tax type 2 (Zero tax rate) and type 3 (Duty free) cannot be used together."
|
|
))
|
|
|
|
tax_type_1_rates = product_lines.tax_ids.filtered(
|
|
lambda t: t.l10n_tw_edi_tax_type == '1'
|
|
).mapped('amount')
|
|
|
|
if "1" in invoice_lines_tax_types and any(rate != 5 for rate in tax_type_1_rates):
|
|
errors.append(self.env._(
|
|
"Amount for Taxable tax type must be 5%."
|
|
))
|
|
else:
|
|
errors.append(self.env._(
|
|
"Please fill in the tax on the invoice lines and select the Ecpay tax type for taxes."
|
|
))
|
|
return errors
|
|
|
|
def _l10n_tw_edi_determine_tax_types(self):
|
|
"""
|
|
Calculate and return the tax type, special tax type and is zero tax rate included based on
|
|
the taxes on invoice lines
|
|
|
|
:return: A tuple containing the tax type information.
|
|
- tax_type (str): The tax type ("1", "2", "3", "4") or "9" for mixed taxes.
|
|
- special_tax_type (int): The special tax type code.
|
|
- is_zero_tax_rate (bool): True if it is zero tax rate.
|
|
"""
|
|
self.ensure_one()
|
|
product_lines = self.invoice_line_ids.filtered(lambda line: line.display_type == "product")
|
|
# Create a set of tax types on invoice lines to check if there are multiple tax types or specific tax type on the invoice lines
|
|
invoice_lines_tax_types = set(product_lines.tax_ids.mapped('l10n_tw_edi_tax_type'))
|
|
|
|
tax_type = False
|
|
special_tax_type = False
|
|
is_zero_tax_rate = False
|
|
|
|
if invoice_lines_tax_types:
|
|
if len(invoice_lines_tax_types) > 1: # mixed tax types
|
|
tax_type = "9"
|
|
special_tax_type = 0
|
|
is_zero_tax_rate = "2" in invoice_lines_tax_types
|
|
elif "4" in invoice_lines_tax_types: # special tax rate
|
|
tax_type = "4"
|
|
special_tax_type = int(product_lines.tax_ids.mapped('l10n_tw_edi_special_tax_type')[0])
|
|
elif "3" in invoice_lines_tax_types:
|
|
tax_type = "3"
|
|
special_tax_type = 8 if product_lines.tax_ids[0].l10n_tw_edi_special_tax_type == "8" else False
|
|
else:
|
|
tax_type = next(iter(invoice_lines_tax_types))
|
|
special_tax_type = 0
|
|
is_zero_tax_rate = "2" in invoice_lines_tax_types
|
|
return tax_type, special_tax_type, is_zero_tax_rate
|
|
|
|
def _l10n_tw_edi_convert_currency_to_twd(self, amount):
|
|
"""
|
|
Convert currency to TWD if the currency is not TWD
|
|
"""
|
|
self.ensure_one()
|
|
if self.currency_id.name == "TWD":
|
|
return amount
|
|
return self.currency_id._convert(amount, self.env.ref("base.TWD"), self.company_id, self.invoice_date or self.date, round=False)
|
|
|
|
def _reformat_phone_number(self, phone):
|
|
"""
|
|
Cleans and reformats a phone number string by handling different input formats.
|
|
|
|
The method first replaces a leading plus sign ('+') with a '0' and removes any
|
|
following space. It then removes all non-digit characters (including spaces,
|
|
dashes, and parentheses) to return a clean, continuous number string.
|
|
|
|
:param phone: The phone number as a string.
|
|
:type phone: str
|
|
:return: A cleaned and reformatted phone number string.
|
|
:rtype: str
|
|
|
|
Example:
|
|
# Replaces leading '+' with '0' and removes spaces/dashes
|
|
_reformat_phone_number('+1 555-123-4567') # returns '05551234567'
|
|
|
|
# Removes spaces and parentheses
|
|
_reformat_phone_number('(555) 123 4567') # returns '5551234567'
|
|
"""
|
|
cleaned_number = phone
|
|
# Replace leading '+' with '0'
|
|
if phone.startswith('+'):
|
|
if ' ' in phone:
|
|
parts = phone.split(' ', 1)
|
|
cleaned_number = '0' + parts[1]
|
|
else:
|
|
cleaned_number = '0' + phone[1:]
|
|
# Remove spaces, dashes, parentheses, etc.
|
|
cleaned_number = re.sub(r'[^\d+]', '', cleaned_number)
|
|
return cleaned_number
|
|
|
|
def _l10n_tw_edi_check_before_generate_invoice_json(self):
|
|
self.ensure_one()
|
|
errors = []
|
|
if not self.company_id.sudo().l10n_tw_edi_ecpay_merchant_id:
|
|
errors.append(self.env._("Please fill in the ECpay API information in the Setting!"))
|
|
|
|
if (self.l10n_tw_edi_is_print or self.partner_id.vat) and not self.partner_id.contact_address:
|
|
errors.append(self.env._("Please fill in the customer address for printing Ecpay invoice."))
|
|
|
|
if not self.partner_id.email and not self.partner_id.phone:
|
|
errors.append(self.env._("Please fill in the customer email or phone number for Ecpay invoice creation."))
|
|
|
|
if self.partner_id.phone:
|
|
formatted_phone = self._reformat_phone_number(self.partner_id.phone)
|
|
if not re.fullmatch(r'[\d]+', formatted_phone):
|
|
errors.append(self.env._("Phone number contains invalid characters! It should be in the format: '+886 0997624293'."))
|
|
|
|
if self.l10n_tw_edi_is_b2b and not self.partner_id.vat:
|
|
errors.append(self.env._("A tax ID is required for company contact or individual contact under a company."))
|
|
|
|
if self.l10n_tw_edi_is_b2b and self.partner_id.vat and (not self.partner_id.vat.isdigit() or len(self.partner_id.vat) != 8):
|
|
errors.append(self.env._("The tax ID is invalid. It should be in the format: '12345678'."))
|
|
|
|
errors.extend(self._l10n_tw_edi_check_tax_type_on_invoice_lines())
|
|
|
|
tax_type, _, is_zero_tax_rate = self._l10n_tw_edi_determine_tax_types()
|
|
|
|
if self.l10n_tw_edi_invoice_type == "07" and tax_type not in ["1", "2", "3", "9"]:
|
|
errors.append(self.env._(
|
|
"Invoice type 07 must be used with tax type 1, 2, 3 or 9. Please check the tax type on the invoice lines."
|
|
))
|
|
|
|
if self.l10n_tw_edi_invoice_type == "08" and tax_type not in ["3", "4"]:
|
|
errors.append(self.env._(
|
|
"Invoice type 08 must be used with tax type 3 or 4. Please check the tax type on the invoice lines."
|
|
))
|
|
|
|
if is_zero_tax_rate:
|
|
if not self.l10n_tw_edi_clearance_mark or not self.l10n_tw_edi_zero_tax_rate_reason:
|
|
errors.append(self.env._(
|
|
"Clearance mark and zero tax rate reason are required for a zero tax rate invoice."
|
|
))
|
|
if errors:
|
|
if 'website_id' in self._fields and self.website_id:
|
|
self.message_post(body="Error:\n" + "\n".join(errors))
|
|
else:
|
|
raise UserError("Error:\n" + "\n".join(errors))
|
|
|
|
def _l10n_tw_edi_prepare_item_list(self, json_data, is_allowance=False):
|
|
self.ensure_one()
|
|
item_list = []
|
|
sale_amount = 0
|
|
tax_amount = 0
|
|
AccountTax = self.env['account.tax']
|
|
tax_type, _, _ = self._l10n_tw_edi_determine_tax_types()
|
|
for index, line in enumerate(self.invoice_line_ids.filtered(lambda line: line.display_type == "product"), start=1):
|
|
base_line = self._prepare_product_base_line_for_taxes_computation(line)
|
|
if is_allowance and self.reversed_entry_id.currency_id == self.currency_id:
|
|
base_line['rate'] = self.reversed_entry_id.invoice_currency_rate # replace the rate by the original invoice's rate
|
|
AccountTax._add_tax_details_in_base_line(base_line, self.company_id)
|
|
|
|
twd_excluded_amount = base_line['tax_details']['raw_total_excluded']
|
|
twd_included_amount = base_line['tax_details']['raw_total_included']
|
|
|
|
if self.l10n_tw_edi_is_b2b:
|
|
item_price = float_round(twd_excluded_amount / line.quantity, precision_rounding=0.01)
|
|
item_amount = float_round(twd_excluded_amount, precision_rounding=0.01)
|
|
else:
|
|
if not is_allowance and line.tax_ids and not line.tax_ids[0].price_include:
|
|
item_price = float_round(twd_excluded_amount / line.quantity, precision_rounding=0.01)
|
|
item_amount = float_round(twd_excluded_amount, precision_rounding=0.01)
|
|
else:
|
|
item_price = float_round(twd_included_amount / line.quantity, precision_rounding=0.01)
|
|
item_amount = float_round(twd_included_amount, precision_rounding=0.01)
|
|
|
|
item_amount_taxed = float_round(twd_included_amount, precision_rounding=0.01)
|
|
|
|
# For special tax, we use twd_included_amount
|
|
if tax_type == "4":
|
|
item_price = float_round(twd_included_amount / line.quantity, precision_rounding=0.01)
|
|
item_amount = float_round(twd_included_amount, precision_rounding=0.01)
|
|
|
|
# Set item sequence for each invoice line, the sequence cannot start from 0
|
|
if not is_allowance:
|
|
line.l10n_tw_edi_ecpay_item_sequence = index
|
|
|
|
if self.l10n_tw_edi_is_b2b and is_allowance:
|
|
item_list.append({
|
|
"OriginalInvoiceNumber": self.l10n_tw_edi_ecpay_invoice_id,
|
|
"OriginalInvoiceDate": self.l10n_tw_edi_invoice_create_date.strftime("%Y-%m-%d"),
|
|
"OriginalSequenceNumber": line.l10n_tw_edi_ecpay_item_sequence,
|
|
"ItemName": line.name[:100],
|
|
"ItemCount": line.quantity,
|
|
"ItemPrice": item_price,
|
|
"ItemAmount": item_amount,
|
|
})
|
|
else:
|
|
item_list.append({
|
|
"ItemSeq": line.l10n_tw_edi_ecpay_item_sequence,
|
|
"ItemName": line.name[:100],
|
|
"ItemCount": line.quantity,
|
|
"ItemWord": line.product_uom_id.name[:6] if line.product_uom_id else False,
|
|
"ItemPrice": item_price,
|
|
"ItemTaxType": line.tax_ids[0].l10n_tw_edi_tax_type if tax_type != "4" and line.tax_ids else "",
|
|
"ItemAmount": item_amount,
|
|
})
|
|
if self.l10n_tw_edi_is_b2b:
|
|
sale_amount += item_amount
|
|
tax_amount += base_line["tax_details"]["taxes_data"][0]["raw_tax_amount"]
|
|
else:
|
|
sale_amount += item_amount_taxed
|
|
|
|
# Sale amount adjustment
|
|
amount_on_invoice = self.amount_untaxed_signed if self.l10n_tw_edi_is_b2b and tax_type != "4" else self.amount_total_signed
|
|
difference = self.company_id.currency_id.round(sale_amount) - abs(amount_on_invoice)
|
|
if difference != 0:
|
|
item_list[-1]["ItemAmount"] -= difference
|
|
item_list[-1]["ItemAmount"] = float_round(item_list[-1]["ItemAmount"], precision_rounding=0.01)
|
|
item_list[-1]["ItemPrice"] = float_round(item_list[-1]["ItemAmount"] / item_list[-1]["ItemCount"], precision_rounding=0.01)
|
|
sale_amount -= difference
|
|
|
|
# Credit note adjustment
|
|
if is_allowance:
|
|
# Check if the credit note has exchange difference, we need to add it to the sale amount
|
|
reconciled_partials = self._get_all_reconciled_invoice_partials()
|
|
exchange_difference = sum(item["amount"] for item in reconciled_partials if item.get("is_exchange"))
|
|
if exchange_difference and self.l10n_tw_edi_is_b2b and tax_type != "4":
|
|
tax_rate = abs(self.amount_untaxed_signed) / abs(self.amount_total_signed)
|
|
exchange_difference = self.company_id.currency_id.round(exchange_difference * tax_rate) # Use untaxed amount for B2B and non special type credit notes
|
|
if self.reversed_entry_id.invoice_currency_rate > self.invoice_currency_rate:
|
|
exchange_difference = exchange_difference * -1 # Convert to negative if the exchange rate is lower than the original invoice rate
|
|
item_list[-1]["ItemAmount"] += exchange_difference
|
|
item_list[-1]["ItemAmount"] = float_round(item_list[-1]["ItemAmount"], precision_rounding=0.01)
|
|
item_list[-1]["ItemPrice"] = float_round(item_list[-1]["ItemAmount"] / item_list[-1]["ItemCount"], precision_rounding=0.01)
|
|
sale_amount += exchange_difference
|
|
|
|
# Check if the credit note has amount due, we need to add it to the sale amount
|
|
item_list[-1]["ItemAmount"] += self.amount_residual_signed
|
|
item_list[-1]["ItemAmount"] = float_round(item_list[-1]["ItemAmount"], precision_rounding=0.01)
|
|
item_list[-1]["ItemPrice"] = float_round(item_list[-1]["ItemAmount"] / item_list[-1]["ItemCount"], precision_rounding=0.01)
|
|
sale_amount += self.amount_residual_signed
|
|
|
|
if self.l10n_tw_edi_is_b2b and is_allowance:
|
|
json_data["Details"] = item_list
|
|
else:
|
|
json_data["Items"] = item_list
|
|
|
|
if not is_allowance:
|
|
json_data["SalesAmount"] = self.company_id.currency_id.round(sale_amount)
|
|
else:
|
|
if self.l10n_tw_edi_is_b2b:
|
|
json_data["TotalAmount"] = self.company_id.currency_id.round(sale_amount)
|
|
else:
|
|
json_data["AllowanceAmount"] = self.company_id.currency_id.round(sale_amount)
|
|
|
|
if self.l10n_tw_edi_is_b2b:
|
|
json_data["TaxAmount"] = self.company_id.currency_id.round(tax_amount) if tax_type != "4" else 0
|
|
|
|
def _l10n_tw_edi_generate_invoice_json(self):
|
|
self.ensure_one()
|
|
self._l10n_tw_edi_check_before_generate_invoice_json()
|
|
tax_type, special_tax_type, is_zero_tax_rate = self._l10n_tw_edi_determine_tax_types()
|
|
self.l10n_tw_edi_related_number = base64.urlsafe_b64encode(uuid.uuid4().bytes)[:20]
|
|
formatted_phone = self._reformat_phone_number(self.partner_id.phone) if self.partner_id.phone else ""
|
|
product_lines = self.invoice_line_ids.filtered(lambda line: line.display_type == "product")
|
|
vat = "1" if product_lines[0].tax_ids and product_lines[0].tax_ids[0].price_include else "0"
|
|
|
|
json_data = {
|
|
"MerchantID": self.company_id.sudo().l10n_tw_edi_ecpay_merchant_id,
|
|
"RelateNumber": self.l10n_tw_edi_related_number,
|
|
"CustomerIdentifier": self.partner_id.vat if self.l10n_tw_edi_is_b2b and self.partner_id.vat else "",
|
|
"CustomerAddr": self.partner_id.contact_address,
|
|
"CustomerEmail": self.partner_id.email or "",
|
|
"CustomerPhone": formatted_phone,
|
|
"InvType": self.l10n_tw_edi_invoice_type,
|
|
"TaxType": tax_type,
|
|
"InvoiceRemark": self.ref,
|
|
}
|
|
|
|
if special_tax_type:
|
|
json_data["SpecialTaxType"] = special_tax_type
|
|
|
|
self._l10n_tw_edi_prepare_item_list(json_data)
|
|
|
|
if self.l10n_tw_edi_is_b2b:
|
|
json_data["TotalAmount"] = json_data["SalesAmount"] + json_data["TaxAmount"]
|
|
else:
|
|
json_data.update({
|
|
"CustomerName": self.partner_id.name,
|
|
"Print": "1" if self.l10n_tw_edi_is_print or self.l10n_tw_edi_is_b2b else "0",
|
|
"Donation": "1" if self.l10n_tw_edi_love_code else "0",
|
|
"LoveCode": self.l10n_tw_edi_love_code or "",
|
|
"CarrierType": self.l10n_tw_edi_carrier_type or "",
|
|
"CarrierNum": self.l10n_tw_edi_carrier_number if self.l10n_tw_edi_carrier_type in ["2", "3", "4", "5"] else "",
|
|
"CarrierNum2": self.l10n_tw_edi_carrier_number_2 if self.l10n_tw_edi_carrier_type in ["4", "5"] else "",
|
|
"vat": vat,
|
|
})
|
|
|
|
if is_zero_tax_rate:
|
|
json_data["ClearanceMark"] = self.l10n_tw_edi_clearance_mark
|
|
json_data["ZeroTaxRateReason"] = self.l10n_tw_edi_zero_tax_rate_reason
|
|
|
|
return json_data
|
|
|
|
def _l10n_tw_edi_check_before_generate_issue_allowance_json(self):
|
|
self.ensure_one()
|
|
if not self.l10n_tw_edi_ecpay_invoice_id:
|
|
raise UserError(self.env._(
|
|
"You cannot issue an allowance for invoice %(invoice_number)s as it was not sent to Ecpay. ",
|
|
invoice_number=self.name
|
|
))
|
|
|
|
if (self.l10n_tw_edi_is_b2b or self.l10n_tw_edi_refund_agreement_type == "online") and not self.partner_id.email:
|
|
raise UserError(self.env._("Customer email is needed for notification"))
|
|
|
|
if not self.l10n_tw_edi_is_b2b and \
|
|
((self.l10n_tw_edi_allowance_notify_way == "email" and not self.partner_id.email) or (self.l10n_tw_edi_allowance_notify_way == "phone" and not self.partner_id.phone)):
|
|
raise UserError(self.env._("Customer %(notify_way)s is needed for notification",
|
|
notify_way=self.l10n_tw_edi_allowance_notify_way))
|
|
|
|
def _l10n_tw_edi_generate_issue_allowance_json(self):
|
|
self.ensure_one()
|
|
self._l10n_tw_edi_check_before_generate_issue_allowance_json()
|
|
json_data = {
|
|
"MerchantID": self.company_id.sudo().l10n_tw_edi_ecpay_merchant_id,
|
|
}
|
|
|
|
self._l10n_tw_edi_prepare_item_list(json_data, is_allowance=True)
|
|
|
|
if not self.l10n_tw_edi_is_b2b:
|
|
if self.l10n_tw_edi_refund_agreement_type == "online":
|
|
json_data["ReturnURL"] = urljoin(
|
|
self.get_base_url(),
|
|
f"/invoice/ecpay/agreed_invoice_allowance/{self.id}?access_token={self._portal_ensure_token()}")
|
|
if self.l10n_tw_edi_allowance_notify_way == "email" and self.partner_id.email:
|
|
json_data["AllowanceNotify"] = "E"
|
|
json_data["NotifyMail"] = self.partner_id.email
|
|
elif self.l10n_tw_edi_allowance_notify_way == "phone" and self.partner_id.phone:
|
|
json_data["AllowanceNotify"] = "S"
|
|
json_data["NotifyPhone"] = self.partner_id.phone.replace("+", "").replace(" ", "")
|
|
|
|
json_data.update({
|
|
"InvoiceNo": self.l10n_tw_edi_ecpay_invoice_id,
|
|
"InvoiceDate": self.l10n_tw_edi_invoice_create_date.strftime("%Y-%m-%d %H:%M:%S"),
|
|
})
|
|
else:
|
|
json_data.update({
|
|
"AllowanceDate": self.l10n_tw_edi_invoice_create_date.strftime("%Y-%m-%d %H:%M:%S"),
|
|
"CustomerEmail": self.partner_id.email,
|
|
})
|
|
|
|
return json_data
|
|
|
|
def _l10n_tw_edi_send(self, json_content):
|
|
"""
|
|
Issuing an e-invoice by calling the Ecpay API and update the invoicing result in Odoo
|
|
"""
|
|
self.ensure_one()
|
|
# Ensure to lock the records that will be sent, to avoid risking sending them twice.
|
|
self.env["res.company"]._with_locked_records(self)
|
|
|
|
response_data = call_ecpay_api("/Issue", json_content, self.company_id, self.l10n_tw_edi_is_b2b)
|
|
if int(response_data.get("RtnCode")) != 1:
|
|
return response_data.get("RtnMsg").split("\r\n")
|
|
invoice_number = response_data.get("InvoiceNumber") if self.l10n_tw_edi_is_b2b else response_data.get("InvoiceNo")
|
|
self.write({
|
|
"l10n_tw_edi_ecpay_invoice_id": invoice_number,
|
|
# The date return from Ecpay API used "+" instead of " "
|
|
"l10n_tw_edi_invoice_create_date": fields.Datetime.now() if self.l10n_tw_edi_is_b2b else transfer_time(
|
|
response_data.get("InvoiceDate").replace("+", " ")),
|
|
"l10n_tw_edi_state": "invoiced",
|
|
})
|
|
self._message_log(body=self.env._(
|
|
"The invoice has been successfully sent to Ecpay with Ecpay invoice number %(invoice_number)s.",
|
|
invoice_number=invoice_number
|
|
))
|
|
|
|
def _l10n_tw_edi_update_ecpay_invoice_info(self):
|
|
"""
|
|
Searching the e-invoice information from Ecpay API and update the invoice information in Odoo
|
|
"""
|
|
self.ensure_one()
|
|
# Ensure to lock the records that will be sent, to avoid risking sending them twice.
|
|
self.env["res.company"]._with_locked_records(self)
|
|
|
|
if not self.l10n_tw_edi_related_number:
|
|
raise UserError(self.env._("The invoice: %(invoice_name)s has no related number", invoice_name=self.name))
|
|
|
|
json_data = {
|
|
"MerchantID": self.company_id.sudo().l10n_tw_edi_ecpay_merchant_id,
|
|
"RelateNumber": self.l10n_tw_edi_related_number,
|
|
}
|
|
|
|
if self.l10n_tw_edi_is_b2b:
|
|
json_data.update({
|
|
"InvoiceCategory": 0,
|
|
"InvoiceNumber": self.l10n_tw_edi_ecpay_invoice_id,
|
|
"InvoiceDate": self.l10n_tw_edi_invoice_create_date.strftime("%Y-%m-%d %H:%M:%S"),
|
|
})
|
|
|
|
response_data = call_ecpay_api("/GetIssue", json_data, self.company_id, self.l10n_tw_edi_is_b2b)
|
|
if int(response_data.get("RtnCode")) != 1:
|
|
return response_data.get("RtnMsg").split("\r\n")
|
|
|
|
invalid_status = int(response_data.get("RtnData").get("Invalid_Status")) if self.l10n_tw_edi_is_b2b else int(response_data.get("IIS_Invalid_Status"))
|
|
self.l10n_tw_edi_state = "valid" if invalid_status == 0 else "invalid"
|
|
|
|
def _l10n_tw_edi_run_invoice_invalid(self):
|
|
"""
|
|
Cancelling the e-invoice by calling the Ecpay API and update the invoice information in Odoo
|
|
"""
|
|
self.ensure_one()
|
|
|
|
# Ensure to lock the records that will be sent, to avoid risking sending them twice.
|
|
self.env["res.company"]._with_locked_records(self)
|
|
|
|
if not self.l10n_tw_edi_ecpay_invoice_id:
|
|
raise UserError(self.env._("You cannot invalidate an invoice that was not sent to Ecpay."))
|
|
if self.l10n_tw_edi_state == "invalid":
|
|
raise UserError(self.env._("The invoice: %(invoice_id)s has already been invalidated",
|
|
invoice_id=self.l10n_tw_edi_ecpay_invoice_id))
|
|
|
|
json_data = {
|
|
"MerchantID": self.company_id.sudo().l10n_tw_edi_ecpay_merchant_id,
|
|
"InvoiceDate": self.l10n_tw_edi_invoice_create_date.strftime("%Y-%m-%d %H:%M:%S"),
|
|
"Reason": self.l10n_tw_edi_invalidate_reason
|
|
}
|
|
|
|
if self.l10n_tw_edi_is_b2b:
|
|
json_data["InvoiceNumber"] = self.l10n_tw_edi_ecpay_invoice_id
|
|
else:
|
|
json_data["InvoiceNo"] = self.l10n_tw_edi_ecpay_invoice_id
|
|
|
|
response_data = call_ecpay_api("/Invalid", json_data, self.company_id, self.l10n_tw_edi_is_b2b)
|
|
|
|
if int(response_data.get("RtnCode")) != 1:
|
|
raise UserError(self.env._("Fail to invalidate invoice. Error message: %(error_message)s",
|
|
error_message=response_data.get("RtnMsg")))
|
|
|
|
# update the invoice information in Odoo
|
|
self._l10n_tw_edi_update_ecpay_invoice_info()
|
|
|
|
self._message_log(
|
|
body=self.env._("Ecpay invoice number %(invoice_number)s has been invalidated successfully.",
|
|
invoice_number=self.l10n_tw_edi_ecpay_invoice_id),
|
|
)
|
|
|
|
def _l10n_tw_edi_issue_allowance(self, json_content):
|
|
"""
|
|
Issuing an allowance by calling the Ecpay API and update the refund invoice information in Odoo
|
|
Two methods to issue the allowance
|
|
1. Endpoint: /Allowance
|
|
General allowance, which requires merchants or sellers to get the agreement from the customer first
|
|
(not by using ECPay system)
|
|
and then to send an API request to ECPay to issue an allowance.
|
|
2. Endpoint: /AllowanceByCollegiate
|
|
Sending an API request to ECPay and ECPay will send an e-mail notification with a link to the customer to
|
|
get his/her agreement
|
|
ONce the customer clicks the link, an allowance will be issued instantly
|
|
"""
|
|
self.ensure_one()
|
|
|
|
# Ensure to lock the records that will be sent, to avoid risking sending them twice.
|
|
self.env["res.company"]._with_locked_records(self)
|
|
|
|
query_param = "/AllowanceByCollegiate" if not self.l10n_tw_edi_is_b2b and self.l10n_tw_edi_refund_agreement_type == "online" else "/Allowance"
|
|
|
|
response_data = call_ecpay_api(query_param, json_content, self.company_id, self.l10n_tw_edi_is_b2b)
|
|
|
|
if int(response_data.get("RtnCode")) != 1:
|
|
raise UserError(self.env._("Fail to issue allowance for ECpay invoice. Error message: %(error_message)s",
|
|
error_message=response_data.get("RtnMsg")))
|
|
|
|
if self.l10n_tw_edi_is_b2b:
|
|
self.l10n_tw_edi_refund_invoice_number = response_data.get("AllowanceNo")
|
|
else:
|
|
self.write({
|
|
"l10n_tw_edi_refund_invoice_number": response_data.get("IA_Allow_No"),
|
|
"l10n_tw_edi_refund_state": "to_be_agreed" if self.l10n_tw_edi_refund_agreement_type == "online" else "agreed",
|
|
})
|
|
|
|
self._message_log(
|
|
body=self.env._("Ecpay invoice number %(invoice_number)s has been issued allowance successfully.",
|
|
invoice_number=self.l10n_tw_edi_ecpay_invoice_id),
|
|
)
|
|
|
|
def _l10n_tw_edi_print_invoice(self):
|
|
self.ensure_one()
|
|
if self.l10n_tw_edi_state not in ['invoiced', 'valid'] or not self.l10n_tw_edi_ecpay_invoice_id or not (self.l10n_tw_edi_is_print or self.l10n_tw_edi_is_b2b):
|
|
raise UserError(self.env._(
|
|
"You cannot print an invoice that was not sent to Ecpay, without the print flag, "
|
|
"or that is invalid."
|
|
))
|
|
return {
|
|
"name": self.env._("Print Ecpay Invoice"),
|
|
"type": "ir.actions.act_window",
|
|
"view_type": "form",
|
|
"view_mode": "form",
|
|
"res_model": "l10n_tw_edi.invoice.print",
|
|
"target": "new",
|
|
"context": {
|
|
"default_invoice_id": self.id,
|
|
},
|
|
}
|