diff --git a/apps/mappings/migrations/0015_generalmapping_is_tax_balancing_enabled.py b/apps/mappings/migrations/0015_generalmapping_is_tax_balancing_enabled.py new file mode 100644 index 00000000..242be4bb --- /dev/null +++ b/apps/mappings/migrations/0015_generalmapping_is_tax_balancing_enabled.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.14 on 2024-11-04 10:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mappings', '0014_auto_20240417_0807'), + ] + + operations = [ + migrations.AddField( + model_name='generalmapping', + name='is_tax_balancing_enabled', + field=models.BooleanField(default=False, help_text='Is tax balancing enabled'), + ), + ] diff --git a/apps/mappings/models.py b/apps/mappings/models.py index 6c9f4346..a8717d79 100644 --- a/apps/mappings/models.py +++ b/apps/mappings/models.py @@ -68,6 +68,7 @@ class GeneralMapping(models.Model): ) override_tax_details = models.BooleanField(default=False, help_text='Override tax details') + is_tax_balancing_enabled = models.BooleanField(default=False, help_text='Is tax balancing enabled') workspace = models.OneToOneField(Workspace, on_delete=models.PROTECT, help_text='Reference to Workspace model', related_name='general_mappings') created_at = models.DateTimeField(auto_now_add=True, help_text='Created at datetime') diff --git a/apps/netsuite/connector.py b/apps/netsuite/connector.py index 4f273ecc..fad7f2cc 100644 --- a/apps/netsuite/connector.py +++ b/apps/netsuite/connector.py @@ -1,5 +1,6 @@ import re import json +import copy from datetime import datetime, timedelta from django.utils import timezone @@ -1161,42 +1162,101 @@ def sync_customers(self): attributes, 'PROJECT', self.workspace_id, True) return [] + + def handle_taxed_line_items(self, base_line, line, workspace_id, export_module): + """ + Handle line items where tax is applied or modified by the user. + :param base_line: The base line item template that will be modified. + :param line: The original line with tax and amount information. + :param is_credit_card_charge: Boolean flag to differentiate between credit card charges and other transactions. + :return: List of lines (taxed and/or untaxed). + """ + general_mapping: GeneralMapping = GeneralMapping.objects.get(workspace_id=workspace_id) + tax_item = DestinationAttribute.objects.filter( + workspace_id=workspace_id, + attribute_type='TAX_ITEM', + destination_id=str(line.tax_item_id) + ).first() + tax_item_rate = tax_item.detail['tax_rate'] - def construct_bill_lineitems( - self, - bill_lineitems: List[BillLineitem], - attachment_links: Dict, - cluster_domain: str, org_id: str, - override_tax_details: bool - ) -> List[Dict]: - """ - Create bill line items - :return: constructed line items - """ - expense_list = [] - item_list = [] + lines = [] + original_amount = round(line.amount, 2) + expected_tax_amount = round((line.amount * (tax_item_rate / 100)) / (1 + (tax_item_rate / 100)), 2) + + if general_mapping.is_tax_balancing_enabled and round(line.tax_amount, 2) != expected_tax_amount: + # Recalculate the net amount based on the modified tax + recalculated_net_amount = round((line.tax_amount * 100) / tax_item_rate, 2) + untaxed_amount = round(original_amount - recalculated_net_amount - line.tax_amount, 2) + + # Create a taxable line item + taxable_line = copy.deepcopy(base_line) + taxable_line['amount'] = recalculated_net_amount + taxable_line['taxCode']['internalId'] = line.tax_item_id + + # Create an untaxed line item + untaxed_line = copy.deepcopy(base_line) + untaxed_line['amount'] = untaxed_amount + untaxed_line['taxCode']['internalId'] = general_mapping.default_tax_code_id # Use default for untaxed items + + if export_module == 'JOURNAL_ENTRY': + taxable_line['grossAmt'] = None + taxable_line['debit'] = recalculated_net_amount + taxable_line.pop('amount', None) + untaxed_line['grossAmt'] = None + untaxed_line['debit'] = untaxed_amount + untaxed_line.pop('amount', None) + + if export_module not in ('CREDIT_CARD_CHARGE', 'BILL'): + taxable_line['tax1Amt'] = round(line.tax_amount, 2) # Tax is applied to this line - for line in bill_lineitems: - expense: Expense = Expense.objects.get(pk=line.expense_id) + if export_module == 'BILL' and taxable_line.get('rate'): + taxable_line['rate'] = str(round(line.amount - line.tax_amount, 2)) - netsuite_custom_segments = line.netsuite_custom_segments + lines.append(taxable_line) + lines.append(untaxed_line) + else: + # When the tax is not modified, just subtract the tax and apply it directly + base_line['amount'] = round(original_amount - line.tax_amount, 2) + base_line['taxCode']['internalId'] = line.tax_item_id + + if export_module not in ('CREDIT_CARD_CHARGE', 'BILL'): + base_line['tax1Amt'] = round(line.tax_amount, 2) # Tax is applied to this line - if attachment_links and expense.expense_id in attachment_links: - netsuite_custom_segments.append( - { - 'scriptId': 'custcolfyle_receipt_link', - 'type': 'String', - 'value': attachment_links[expense.expense_id] - } - ) - netsuite_custom_segments.append( - { - 'scriptId': 'custcolfyle_receipt_link_2', - 'type': 'String', - 'value': attachment_links[expense.expense_id] - } - ) + if export_module == 'BILL' and base_line.get('rate'): + base_line['rate'] = str(round(line.amount - line.tax_amount, 2)) + + if export_module == 'JOURNAL_ENTRY': + base_line['grossAmt'] = original_amount + base_line['debit'] = round(original_amount - line.tax_amount, 2) + base_line.pop('amount', None) + + lines.append(base_line) + + return lines + + def prepare_custom_segments(self, line_netsuite_custom_segments, attachment_links, expense, org_id, is_credit=False): + """ + Prepare custom segments for line items. + """ + netsuite_custom_segments = line_netsuite_custom_segments + if attachment_links and expense.expense_id in attachment_links: + netsuite_custom_segments.append( + { + 'scriptId': 'custcolfyle_receipt_link', + 'type': 'String', + 'value': attachment_links[expense.expense_id] + } + ) + netsuite_custom_segments.append( + { + 'scriptId': 'custcolfyle_receipt_link_2', + 'type': 'String', + 'value': attachment_links[expense.expense_id] + } + ) + + if not is_credit: netsuite_custom_segments.append( { 'scriptId': 'custcolfyle_expense_url', @@ -1220,13 +1280,34 @@ def construct_bill_lineitems( } ) - lineitem = { + return netsuite_custom_segments + + def construct_bill_lineitems( + self, + bill_lineitems: List[BillLineitem], + attachment_links: Dict, + cluster_domain: str, org_id: str, + override_tax_details: bool + ) -> List[Dict]: + """ + Create bill line items + :return: constructed line items + """ + expense_list = [] + item_list = [] + + for line in bill_lineitems: + expense: Expense = Expense.objects.get(pk=line.expense_id) + + netsuite_custom_segments = self.prepare_custom_segments(line.netsuite_custom_segments, attachment_links, expense, org_id) + + base_line = { 'orderDoc': None, 'orderLine': None, 'line': None, - 'amount': line.amount - line.tax_amount if (line.tax_item_id and line.tax_amount is not None) else line.amount, - 'grossAmt': None if override_tax_details else line.amount, - 'taxDetailsReference': expense.expense_number if override_tax_details else None, + 'amount': line.amount, + 'grossAmt': line.amount, + 'taxDetailsReference': None, 'department': { 'name': None, 'internalId': line.department_id, @@ -1254,10 +1335,10 @@ def construct_bill_lineitems( 'customFieldList': netsuite_custom_segments, 'isBillable': line.billable, 'tax1Amt': None, - 'taxAmount': line.tax_amount if (line.tax_item_id and line.tax_amount is not None and not override_tax_details) else None, + 'taxAmount': None, 'taxCode':{ 'name': None, - 'internalId': line.tax_item_id if (line.tax_item_id and line.tax_amount is not None and not override_tax_details) else None, + 'internalId': None, 'externalId': None, 'type': 'taxGroup' }, @@ -1270,41 +1351,59 @@ def construct_bill_lineitems( } if line.detail_type == 'AccountBasedExpenseLineDetail': - lineitem['account'] = { + base_line['account'] = { 'name': None, 'internalId': line.account_id, 'externalId': None, 'type': 'account' } - lineitem['category'] = None - lineitem['memo'] = line.memo - lineitem['projectTask'] = None + base_line['category'] = None + base_line['memo'] = line.memo + base_line['projectTask'] = None - expense_list.append(lineitem) + if line.tax_item_id is None or line.tax_amount is None: + expense_list.append(base_line) + else: + if override_tax_details: + base_line['grossAmt'] = None + base_line['taxDetailsReference'] = expense.expense_number + base_line['amount'] = line.amount - line.tax_amount + expense_list.append(base_line) + else: + expense_list += self.handle_taxed_line_items(base_line, line, expense.workspace_id, 'BILL') else: - lineitem['item'] = { + base_line['item'] = { 'name': None, 'internalId': line.item_id, 'externalId': None, 'type': None } - lineitem['vendorName'] = None - lineitem['quantity'] = 1.0 - lineitem['units'] = None - lineitem['inventoryDetail'] = None - lineitem['description'] = line.memo - lineitem['serialNumbers'] = None - lineitem['binNumbers'] = None - lineitem['expirationDate'] = None - lineitem['rate'] = str(line.amount - line.tax_amount if (line.tax_item_id and line.tax_amount is not None) else line.amount) - lineitem['options'] = None - lineitem['landedCostCategory'] = None - lineitem['billVarianceStatus'] = None - lineitem['billreceiptsList'] = None - lineitem['landedCost'] = None - - item_list.append(lineitem) + base_line['vendorName'] = None + base_line['quantity'] = 1.0 + base_line['units'] = None + base_line['inventoryDetail'] = None + base_line['description'] = line.memo + base_line['serialNumbers'] = None + base_line['binNumbers'] = None + base_line['expirationDate'] = None + base_line['rate'] = str(line.amount) + base_line['options'] = None + base_line['landedCostCategory'] = None + base_line['billVarianceStatus'] = None + base_line['billreceiptsList'] = None + base_line['landedCost'] = None + + if line.tax_item_id is None or line.tax_amount is None: + item_list.append(base_line) + else: + if override_tax_details: + base_line['grossAmt'] = None + base_line['taxDetailsReference'] = expense.expense_number + base_line['amount'] = line.amount - line.tax_amount + item_list.append(base_line) + else: + item_list += self.handle_taxed_line_items(base_line, line, expense.workspace_id, 'BILL') return expense_list, item_list @@ -1494,74 +1593,31 @@ def construct_credit_card_charge_lineitems( expense = Expense.objects.get(pk=line.expense_id) - netsuite_custom_segments = line.netsuite_custom_segments + netsuite_custom_segments = self.prepare_custom_segments(line.netsuite_custom_segments, attachment_links, expense, org_id) - if attachment_links and expense.expense_id in attachment_links: - netsuite_custom_segments.append( - { - 'scriptId': 'custcolfyle_receipt_link', - 'value': attachment_links[expense.expense_id] - } - ) - netsuite_custom_segments.append( - { - 'scriptId': 'custcolfyle_receipt_link_2', - 'type': 'String', - 'value': attachment_links[expense.expense_id] - } - ) - - netsuite_custom_segments.append( - { - 'scriptId': 'custcolfyle_expense_url', - 'value': '{}/app/admin/#/enterprise/view_expense/{}?org_id={}'.format( - settings.FYLE_EXPENSE_URL, - expense.expense_id, - org_id - ) - } - ) - netsuite_custom_segments.append( - { - 'scriptId': 'custcolfyle_expense_url_2', - 'value': '{}/app/admin/#/enterprise/view_expense/{}?org_id={}'.format( - settings.FYLE_EXPENSE_URL, - expense.expense_id, - org_id - ) - } - ) - - line = { - 'account': { - 'internalId': line.account_id - }, - 'amount': line.amount - line.tax_amount if (line.tax_item_id and line.tax_amount is not None) else line.amount, + base_line = { + 'account': {'internalId': line.account_id}, + 'amount': line.amount, 'memo': line.memo, 'grossAmt': line.amount, - 'department': { - 'internalId': line.department_id - }, - 'class': { - 'internalId': line.class_id - }, - 'location': { - 'internalId': line.location_id - }, - 'customer': { - 'internalId': line.customer_id - }, + 'department': {'internalId': line.department_id}, + 'class': {'internalId': line.class_id}, + 'location': {'internalId': line.location_id}, + 'customer': {'internalId': line.customer_id}, 'customFieldList': netsuite_custom_segments, 'isBillable': line.billable, 'taxAmount': None, 'taxCode': { - 'name': None, - 'internalId': line.tax_item_id if (line.tax_item_id and line.tax_amount is not None) else None, - 'externalId': None, + 'internalId': None, 'type': 'taxGroup' - }, + } } - lines.append(line) + + # Handle cases where no tax is applied first + if line.tax_item_id is None or line.tax_amount is None: + lines.append(base_line) + else: + lines += self.handle_taxed_line_items(base_line, line, expense.workspace_id, 'CREDIT_CARD_CHARGE') return lines @@ -1707,7 +1763,7 @@ def construct_expense_report_lineitems( for line in expense_report_lineitems: expense: Expense = Expense.objects.get(pk=line.expense_id) - netsuite_custom_segments = line.netsuite_custom_segments + netsuite_custom_segments = self.prepare_custom_segments(line.netsuite_custom_segments, attachment_links, expense, org_id) if expense.foreign_amount: if expense.amount == 0: @@ -1717,47 +1773,8 @@ def construct_expense_report_lineitems( else: foreign_amount = None - if attachment_links and expense.expense_id in attachment_links: - netsuite_custom_segments.append( - { - 'scriptId': 'custcolfyle_receipt_link', - 'type': 'String', - 'value': attachment_links[expense.expense_id] - } - ) - netsuite_custom_segments.append( - { - 'scriptId': 'custcolfyle_receipt_link_2', - 'type': 'String', - 'value': attachment_links[expense.expense_id] - } - ) - - netsuite_custom_segments.append( - { - 'scriptId': 'custcolfyle_expense_url', - 'type': 'String', - 'value': '{}/app/admin/#/enterprise/view_expense/{}?org_id={}'.format( - settings.FYLE_EXPENSE_URL, - expense.expense_id, - org_id - ) - } - ) - netsuite_custom_segments.append( - { - 'scriptId': 'custcolfyle_expense_url_2', - 'type': 'String', - 'value': '{}/app/admin/#/enterprise/view_expense/{}?org_id={}'.format( - settings.FYLE_EXPENSE_URL, - expense.expense_id, - org_id - ) - } - ) - - lineitem = { - 'amount': line.amount - line.tax_amount if (line.tax_item_id and line.tax_amount is not None) else line.amount, + base_line = { + 'amount': line.amount, 'category': { 'name': None, 'internalId': line.category, @@ -1809,10 +1826,10 @@ def construct_expense_report_lineitems( 'rate': None, 'receipt': None, 'refNumber': None, - 'tax1Amt': line.tax_amount if (line.tax_item_id and line.tax_amount is not None) else None, + 'tax1Amt': None, 'taxCode': { 'name': None, - 'internalId': line.tax_item_id if (line.tax_item_id and line.tax_amount is not None) else None, + 'internalId': None, 'externalId': None, 'type': 'taxGroup' }, @@ -1820,7 +1837,11 @@ def construct_expense_report_lineitems( 'taxRate2': None } - lines.append(lineitem) + # Handle cases where no tax is applied first + if line.tax_item_id is None or line.tax_amount is None: + lines.append(base_line) + else: + lines += self.handle_taxed_line_items(base_line, line, expense.workspace_id, 'EXPENSE_REPORT') return lines @@ -1980,50 +2001,9 @@ def construct_journal_entry_lineitems(self, journal_entry_lineitems: List[Journa if debit is None: account_ref = line.debit_account_id - netsuite_custom_segments = line.netsuite_custom_segments + netsuite_custom_segments = self.prepare_custom_segments(line.netsuite_custom_segments, attachment_links, expense, org_id, credit) - if attachment_links and expense.expense_id in attachment_links: - netsuite_custom_segments.append( - { - 'scriptId': 'custcolfyle_receipt_link', - 'type': 'String', - 'value': attachment_links[expense.expense_id] - } - ) - netsuite_custom_segments.append( - { - 'scriptId': 'custcolfyle_receipt_link_2', - 'type': 'String', - 'value': attachment_links[expense.expense_id] - } - ) - - if debit: - netsuite_custom_segments.append( - { - 'scriptId': 'custcolfyle_expense_url', - 'type': 'String', - 'value': '{}/app/admin/#/enterprise/view_expense/{}?org_id={}'.format( - settings.FYLE_EXPENSE_URL, - expense.expense_id, - org_id - ) - } - ) - netsuite_custom_segments.append( - { - 'scriptId': 'custcolfyle_expense_url_2', - 'type': 'String', - 'value': '{}/app/admin/#/enterprise/view_expense/{}?org_id={}'.format( - settings.FYLE_EXPENSE_URL, - expense.expense_id, - org_id - ) - } - ) - - tax_inclusive_amount = round((line.amount - line.tax_amount), 2) if (line.tax_amount is not None and line.tax_item_id ) else line.amount - lineitem = { + base_line = { 'account': { 'name': None, 'internalId': account_ref, @@ -2054,14 +2034,14 @@ def construct_journal_entry_lineitems(self, journal_entry_lineitems: List[Journa 'externalId': None, 'type': 'vendor' }, - 'credit': line.amount if credit is not None else None, + 'credit': None, 'creditTax': None, 'customFieldList': netsuite_custom_segments, - 'debit': tax_inclusive_amount if debit is not None else None, + 'debit': line.amount, 'debitTax': None, 'eliminate': None, 'endDate': None, - 'grossAmt': line.amount if (line.tax_amount is not None and line.tax_item_id and debit is not None) else None, + 'grossAmt': None, 'line': None, 'lineTaxCode': None, 'lineTaxRate': None, @@ -2074,18 +2054,28 @@ def construct_journal_entry_lineitems(self, journal_entry_lineitems: List[Journa 'tax1Acct': None, 'taxAccount': None, 'taxBasis': None, - 'tax1Amt': line.tax_amount if (line.tax_amount is not None and line.tax_item_id and debit is not None) else None, + 'tax1Amt': None, 'taxCode': { 'name': None, - 'internalId': line.tax_item_id if (line.tax_amount is not None and line.tax_item_id ) else None, + 'internalId': None, 'externalId': None, 'type': 'taxGroup' - } if debit is not None else None, + }, 'taxRate1': None, 'totalAmount': None, } - lines.append(lineitem) + + if debit: + if line.tax_item_id is None or line.tax_amount is None: + lines.append(base_line) + else: + lines += self.handle_taxed_line_items(base_line, line, expense.workspace_id, 'JOURNAL_ENTRY') + elif credit: + base_line['credit'] = line.amount + base_line['debit'] = None + lines.append(base_line) + return lines