From e1166da863c2d84b09a3ce2e32d0d276c897280b Mon Sep 17 00:00:00 2001 From: Viswas Haridas <37623357+JustARatherRidiculouslyLongUsername@users.noreply.github.com> Date: Mon, 25 Nov 2024 13:14:30 +0530 Subject: [PATCH] feat: support split expense grouping (#681) * test: write unit tests and add fixtures for split expense grouping (#670) * test: write unit tests and add fixtures for split expense grouping * refactor: remove duplicate bank txn IDs * test: fix failing tests * feat: make db changes and fixture updates to support split expenses (#680) * feat: make db changes and fixture updates to support split expenses * feat: support GET and PUT for `split_expense_grouping` in export settings (#672) * feat: support GET and PUT for `split_expense_grouping` in export settings * feat: implement split expense grouping functionality (#675) * feat: implement split expense grouping functionality * feat: add script for split expense grouping (#676) * feat: add script for split expense grouping Set the default split expense grouping config to SINGLE_LINE_ITEM for all old orgs * test: add missing fixtures * feat: support multiple line items for CC charge exports (#677) * feat: support multiple line items for CC charge exports * test: update tests to account for the new function signature * test: update tests and fixtures Account for tax balancing and credit card charge lineitems changes * refactor: move url out of `for` loop (cherry picked from commit c0309e8b7bb5b5d7ff4071f9b6243718e4014846) --- .../0035_support_split_expense_grouping.py | 24 +++ apps/fyle/models.py | 62 +++++- apps/netsuite/connector.py | 76 ++++---- apps/netsuite/models.py | 134 ++++++------- apps/netsuite/tasks.py | 9 +- .../apis/export_settings/serializers.py | 4 +- ...lt-split-expense-grouping-for-old-orgs.sql | 5 + .../reset_db_fixtures/reset_db.sql | 37 ++-- tests/test_fyle/conftest.py | 18 ++ tests/test_fyle/fixtures.py | 178 +++++++++++++++++- tests/test_fyle/test_models.py | 116 +++++++++++- tests/test_netsuite/conftest.py | 2 +- tests/test_netsuite/test_connector.py | 26 +-- tests/test_netsuite/test_models.py | 18 +- .../test_export_settings/fixtures.py | 6 +- 15 files changed, 558 insertions(+), 157 deletions(-) create mode 100644 apps/fyle/migrations/0035_support_split_expense_grouping.py create mode 100644 scripts/sql/scripts/030-default-split-expense-grouping-for-old-orgs.sql diff --git a/apps/fyle/migrations/0035_support_split_expense_grouping.py b/apps/fyle/migrations/0035_support_split_expense_grouping.py new file mode 100644 index 00000000..93fddfa7 --- /dev/null +++ b/apps/fyle/migrations/0035_support_split_expense_grouping.py @@ -0,0 +1,24 @@ +# Generated by Django 3.2.14 on 2024-11-18 15:00 + +import apps.fyle.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('fyle', '0034_expense_is_posted_at_null'), + ] + + operations = [ + migrations.AddField( + model_name='expense', + name='bank_transaction_id', + field=models.CharField(blank=True, help_text='Bank Transaction ID', max_length=255, null=True), + ), + migrations.AddField( + model_name='expensegroupsettings', + name='split_expense_grouping', + field=models.CharField(choices=[('SINGLE_LINE_ITEM', 'SINGLE_LINE_ITEM'), ('MULTIPLE_LINE_ITEM', 'MULTIPLE_LINE_ITEM')], default=apps.fyle.models.get_default_split_expense_grouping, help_text='specify line items for split expenses grouping', max_length=100), + ), + ] diff --git a/apps/fyle/models.py b/apps/fyle/models.py index 087ab4dd..4182d338 100644 --- a/apps/fyle/models.py +++ b/apps/fyle/models.py @@ -25,7 +25,8 @@ ALLOWED_FIELDS = [ 'employee_email', 'report_id', 'claim_number', 'settlement_id', 'fund_source', 'vendor', 'category', 'project', 'cost_center', - 'verified_at', 'approved_at', 'spent_at', 'expense_id', 'posted_at' + 'verified_at', 'approved_at', 'spent_at', 'expense_id', 'posted_at', + 'bank_transaction_id' ] @@ -72,6 +73,8 @@ ('not_in', 'not_in') ) +SPLIT_EXPENSE_GROUPING = (('SINGLE_LINE_ITEM', 'SINGLE_LINE_ITEM'), ('MULTIPLE_LINE_ITEM', 'MULTIPLE_LINE_ITEM')) + class Expense(models.Model): """ Expense @@ -103,6 +106,7 @@ class Expense(models.Model): report_id = models.CharField(max_length=255, help_text='Report ID') report_title = models.TextField(null=True, blank=True, help_text='Report title') corporate_card_id = models.CharField(max_length=255, null=True, blank=True, help_text='Corporate Card ID') + bank_transaction_id = models.CharField(max_length=255, null=True, blank=True, help_text='Bank Transaction ID') file_ids = ArrayField(base_field=models.CharField(max_length=255), null=True, help_text='File IDs') spent_at = models.DateTimeField(null=True, help_text='Expense spent at') approved_at = models.DateTimeField(null=True, help_text='Expense approved at') @@ -173,6 +177,7 @@ def create_expense_objects(expenses: List[Dict], workspace_id, skip_update: bool 'purpose': expense['purpose'], 'report_id': expense['report_id'], 'corporate_card_id': expense['corporate_card_id'], + 'bank_transaction_id': expense['bank_transaction_id'], 'file_ids': expense['file_ids'], 'spent_at': expense['spent_at'], 'posted_at': expense['posted_at'], @@ -207,6 +212,9 @@ def get_default_expense_state(): def get_default_ccc_expense_state(): return 'PAID' +def get_default_split_expense_grouping(): + return 'MULTIPLE_LINE_ITEM' + class ExpenseGroupSettings(models.Model): """ @@ -232,6 +240,11 @@ class ExpenseGroupSettings(models.Model): reimbursable_export_date_type = models.CharField(max_length=100, default='current_date', help_text='Export Date') ccc_export_date_type = models.CharField(max_length=100, default='current_date', help_text='CCC Export Date') import_card_credits = models.BooleanField(help_text='Import Card Credits', default=False) + split_expense_grouping = models.CharField( + max_length=100, + default=get_default_split_expense_grouping, + choices=SPLIT_EXPENSE_GROUPING, help_text='specify line items for split expenses grouping' + ) workspace = models.OneToOneField( Workspace, on_delete=models.PROTECT, help_text='To which workspace this expense group setting belongs to', related_name = 'expense_group_settings' @@ -311,7 +324,8 @@ def update_expense_group_settings(expense_group_settings: Dict, workspace_id: in 'expense_state': expense_group_settings['expense_state'], 'ccc_expense_state': expense_group_settings['ccc_expense_state'], 'reimbursable_export_date_type': expense_group_settings['reimbursable_export_date_type'], - 'ccc_export_date_type': expense_group_settings['ccc_export_date_type'] + 'ccc_export_date_type': expense_group_settings['ccc_export_date_type'], + 'split_expense_grouping': expense_group_settings['split_expense_grouping'] } ) @@ -362,6 +376,7 @@ def create_expense_groups_by_report_id_fund_source(expense_objects: List[Expense """ expense_group_settings = ExpenseGroupSettings.objects.get(workspace_id=workspace_id) + # Group Reimbursable Expenses reimbursable_expense_group_fields = expense_group_settings.reimbursable_expense_group_fields reimbursable_expenses = list(filter(lambda expense: expense.fund_source == 'PERSONAL', expense_objects)) @@ -392,6 +407,8 @@ def create_expense_groups_by_report_id_fund_source(expense_objects: List[Expense reimbursable_expenses = list(filter(lambda expense: expense.amount > 0, reimbursable_expenses)) expense_groups = _group_expenses(reimbursable_expenses, reimbursable_expense_group_fields, workspace_id) + + # Group CCC Expenses corporate_credit_card_expense_group_field = expense_group_settings.corporate_credit_card_expense_group_fields corporate_credit_card_expenses = list(filter(lambda expense: expense.fund_source == 'CCC', expense_objects)) @@ -401,10 +418,45 @@ def create_expense_groups_by_report_id_fund_source(expense_objects: List[Expense filter(lambda expense: expense.amount > 0, corporate_credit_card_expenses) ) - corporate_credit_card_expense_groups = _group_expenses( - corporate_credit_card_expenses, corporate_credit_card_expense_group_field, workspace_id) + if corporate_credit_card_expenses: + # Group split Credit Card Charges by `bank_transaction_id` + if ( + configuration.corporate_credit_card_expenses_object == 'CREDIT CARD CHARGE' and + expense_group_settings.split_expense_grouping == 'MULTIPLE_LINE_ITEM' + ): + ccc_expenses_without_bank_transaction_id = list( + filter(lambda expense: not expense.bank_transaction_id, corporate_credit_card_expenses) + ) + + ccc_expenses_with_bank_transaction_id = list( + filter(lambda expense: expense.bank_transaction_id, corporate_credit_card_expenses) + ) + + if ccc_expenses_without_bank_transaction_id: + groups_without_bank_transaction_id = _group_expenses( + ccc_expenses_without_bank_transaction_id, corporate_credit_card_expense_group_field, workspace_id + ) + + expense_groups.extend(groups_without_bank_transaction_id) + + if ccc_expenses_with_bank_transaction_id: + split_expense_group_fields = [ + field for field in corporate_credit_card_expense_group_field + if field not in ('expense_id', 'expense_number') + ] + split_expense_group_fields.append('bank_transaction_id') + + groups_with_bank_transaction_id = _group_expenses( + ccc_expenses_with_bank_transaction_id, split_expense_group_fields, workspace_id + ) + expense_groups.extend(groups_with_bank_transaction_id) + + else: + corporate_credit_card_expense_groups = _group_expenses( + corporate_credit_card_expenses, corporate_credit_card_expense_group_field, workspace_id) + + expense_groups.extend(corporate_credit_card_expense_groups) - expense_groups.extend(corporate_credit_card_expense_groups) for expense_group in expense_groups: if expense_group_settings.reimbursable_export_date_type == 'last_spent_at': expense_group['last_spent_at'] = Expense.objects.filter( diff --git a/apps/netsuite/connector.py b/apps/netsuite/connector.py index d3baa9f5..431d04dd 100644 --- a/apps/netsuite/connector.py +++ b/apps/netsuite/connector.py @@ -1581,51 +1581,50 @@ def get_bill(self, internal_id): return bill def construct_credit_card_charge_lineitems( - self, credit_card_charge_lineitem: CreditCardChargeLineItem, general_mapping: GeneralMapping, + self, credit_card_charge_lineitems: List[CreditCardChargeLineItem], general_mapping: GeneralMapping, attachment_links: Dict, cluster_domain: str, org_id: str) -> List[Dict]: """ Create credit_card_charge line items :return: constructed line items """ - line = credit_card_charge_lineitem - lines = [] - 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 = { - '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}, - 'customFieldList': netsuite_custom_segments, - 'isBillable': line.billable, - 'taxAmount': None, - 'taxCode': { - 'externalId': None, - 'internalId': None, - 'name': None, - 'type': 'taxGroup' - }, - } + for line in credit_card_charge_lineitems: + expense = Expense.objects.get(pk=line.expense_id) - # 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', general_mapping) + netsuite_custom_segments = self.prepare_custom_segments(line.netsuite_custom_segments, attachment_links, expense, org_id) + + 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}, + 'customFieldList': netsuite_custom_segments, + 'isBillable': line.billable, + 'taxAmount': None, + 'taxCode': { + 'externalId': None, + 'internalId': None, + 'name': None, + 'type': 'taxGroup' + }, + } + + # 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', general_mapping) return lines def __construct_credit_card_charge( self, credit_card_charge: CreditCardCharge, - credit_card_charge_lineitem: CreditCardChargeLineItem, general_mapping: GeneralMapping, attachment_links: Dict) -> Dict: + credit_card_charge_lineitems: List[CreditCardChargeLineItem], general_mapping: GeneralMapping, attachment_links: Dict) -> Dict: """ Create a credit_card_charge :return: constructed credit_card_charge @@ -1664,7 +1663,7 @@ def __construct_credit_card_charge( 'memo': credit_card_charge.memo, 'tranid': credit_card_charge.reference_number, 'expenses': self.construct_credit_card_charge_lineitems( - credit_card_charge_lineitem, general_mapping, attachment_links, cluster_domain, org_id + credit_card_charge_lineitems, general_mapping, attachment_links, cluster_domain, org_id ), 'externalId': credit_card_charge.external_id } @@ -1672,7 +1671,7 @@ def __construct_credit_card_charge( return credit_card_charge_payload def post_credit_card_charge(self, credit_card_charge: CreditCardCharge, - credit_card_charge_lineitem: CreditCardChargeLineItem, general_mapping: GeneralMapping, attachment_links: Dict, + credit_card_charge_lineitems: List[CreditCardChargeLineItem], general_mapping: GeneralMapping, attachment_links: Dict, refund: bool): """ Post vendor credit_card_charges to NetSuite @@ -1694,12 +1693,15 @@ def post_credit_card_charge(self, credit_card_charge: CreditCardCharge, f"script=customscript_cc_charge_fyle&deploy=customdeploy_cc_charge_fyle" if refund: - credit_card_charge_lineitem.amount = abs(credit_card_charge_lineitem.amount) + for credit_card_charge_lineitem in credit_card_charge_lineitems: + credit_card_charge_lineitem.amount = abs(credit_card_charge_lineitem.amount) + credit_card_charge_lineitem.save() + url = f"https://{account.lower()}.restlets.api.netsuite.com/app/site/hosting/restlet.nl?" \ - f"script=customscript_cc_refund_fyle&deploy=customdeploy_cc_refund_fyle" + f"script=customscript_cc_refund_fyle&deploy=customdeploy_cc_refund_fyle" credit_card_charges_payload = self.__construct_credit_card_charge( - credit_card_charge, credit_card_charge_lineitem, general_mapping, attachment_links) + credit_card_charge, credit_card_charge_lineitems, general_mapping, attachment_links) logger.info("| Payload for Credit Card Charge creation | Content: {{WORKSPACE_ID: {} EXPENSE_GROUP_ID: {} CREDIT_CARD_CHARGE_PAYLOAD: {}}}".format(self.workspace_id, credit_card_charge.expense_group.id, credit_card_charges_payload)) diff --git a/apps/netsuite/models.py b/apps/netsuite/models.py index d69657b4..580428d1 100644 --- a/apps/netsuite/models.py +++ b/apps/netsuite/models.py @@ -684,103 +684,103 @@ class Meta: db_table = 'credit_card_charge_lineitems' @staticmethod - def create_credit_card_charge_lineitem(expense_group: ExpenseGroup, configuration: Configuration): + def create_credit_card_charge_lineitems(expense_group: ExpenseGroup, configuration: Configuration): """ Create credit card charge lineitems :param expense_group: expense group :param configuration: Workspace Configuration Settings :return: credit card charge lineitems objects """ - lineitem = expense_group.expenses.first() credit_card_charge = CreditCardCharge.objects.get(expense_group=expense_group) general_mappings = GeneralMapping.objects.get(workspace_id=expense_group.workspace_id) credit_card_charge_lineitem_objects = [] + for lineitem in expense_group.expenses.all(): - category = lineitem.category if (lineitem.category == lineitem.sub_category or lineitem.sub_category == None) else '{0} / {1}'.format( - lineitem.category, lineitem.sub_category) - - account = CategoryMapping.objects.filter( - source_category__value=category, - workspace_id=expense_group.workspace_id - ).first() - - class_id = None - if expense_group.fund_source == 'CCC' and general_mappings.use_employee_class: - employee_mapping = EmployeeMapping.objects.filter( - source_employee__value=expense_group.description.get('employee_email'), - workspace_id=expense_group.workspace_id - ).first() - if employee_mapping and employee_mapping.destination_employee: - class_id = employee_mapping.destination_employee.detail.get('class_id') - - if not class_id: - class_id = get_class_id_or_none(expense_group, lineitem) - - department_id = get_department_id_or_none(expense_group, lineitem) + category = lineitem.category if (lineitem.category == lineitem.sub_category or lineitem.sub_category == None) else '{0} / {1}'.format( + lineitem.category, lineitem.sub_category) - if expense_group.fund_source == 'CCC' and general_mappings.use_employee_department and \ - general_mappings.department_level in ('ALL', 'TRANSACTION_LINE'): - employee_mapping = EmployeeMapping.objects.filter( - source_employee__value=expense_group.description.get('employee_email'), + account = CategoryMapping.objects.filter( + source_category__value=category, workspace_id=expense_group.workspace_id ).first() - if employee_mapping and employee_mapping.destination_employee: - if employee_mapping.destination_employee.detail.get('department_id'): - department_id = employee_mapping.destination_employee.detail.get('department_id') - if not department_id: - if general_mappings.department_id and general_mappings.department_level in ['TRANSACTION_LINE', 'ALL']: - department_id = general_mappings.department_id + class_id = None + if expense_group.fund_source == 'CCC' and general_mappings.use_employee_class: + employee_mapping = EmployeeMapping.objects.filter( + source_employee__value=expense_group.description.get('employee_email'), + workspace_id=expense_group.workspace_id + ).first() + if employee_mapping and employee_mapping.destination_employee: + class_id = employee_mapping.destination_employee.detail.get('class_id') + + if not class_id: + class_id = get_class_id_or_none(expense_group, lineitem) - location_id = get_location_id_or_none(expense_group, lineitem) + department_id = get_department_id_or_none(expense_group, lineitem) - if expense_group.fund_source == 'CCC' and general_mappings.use_employee_location and\ - general_mappings.location_level in ('ALL', 'TRANSACTION_LINE'): + if expense_group.fund_source == 'CCC' and general_mappings.use_employee_department and \ + general_mappings.department_level in ('ALL', 'TRANSACTION_LINE'): employee_mapping = EmployeeMapping.objects.filter( source_employee__value=expense_group.description.get('employee_email'), workspace_id=expense_group.workspace_id ).first() if employee_mapping and employee_mapping.destination_employee: - location_id = employee_mapping.destination_employee.detail.get('location_id') + if employee_mapping.destination_employee.detail.get('department_id'): + department_id = employee_mapping.destination_employee.detail.get('department_id') - if not location_id: - if general_mappings.location_id and general_mappings.location_level in ['TRANSACTION_LINE', 'ALL']: - location_id = general_mappings.location_id + if not department_id: + if general_mappings.department_id and general_mappings.department_level in ['TRANSACTION_LINE', 'ALL']: + department_id = general_mappings.department_id + + location_id = get_location_id_or_none(expense_group, lineitem) + + if expense_group.fund_source == 'CCC' and general_mappings.use_employee_location and\ + general_mappings.location_level in ('ALL', 'TRANSACTION_LINE'): + employee_mapping = EmployeeMapping.objects.filter( + source_employee__value=expense_group.description.get('employee_email'), + workspace_id=expense_group.workspace_id + ).first() + if employee_mapping and employee_mapping.destination_employee: + location_id = employee_mapping.destination_employee.detail.get('location_id') + + if not location_id: + if general_mappings.location_id and general_mappings.location_level in ['TRANSACTION_LINE', 'ALL']: + location_id = general_mappings.location_id - custom_segments = get_custom_segments(expense_group, lineitem) + custom_segments = get_custom_segments(expense_group, lineitem) - customer_id = get_customer_id_or_none(expense_group, lineitem) + customer_id = get_customer_id_or_none(expense_group, lineitem) - billable = lineitem.billable - if customer_id: - if not billable: + billable = lineitem.billable + if customer_id: + if not billable: + billable = False + else: billable = False - else: - billable = False - credit_card_charge_lineitem_object, _ = CreditCardChargeLineItem.objects.update_or_create( - credit_card_charge=credit_card_charge, - expense_id=lineitem.id, - defaults={ - 'account_id': account.destination_account.destination_id \ - if account and account.destination_account else None, - 'location_id': location_id, - 'class_id': class_id, - 'department_id': department_id, - 'customer_id': customer_id, - 'amount': lineitem.amount, - 'tax_item_id': get_tax_item_id_or_none(expense_group, general_mappings,lineitem), - 'tax_amount': lineitem.tax_amount, - 'billable': billable, - 'memo': get_expense_purpose(lineitem, category, configuration), - 'netsuite_custom_segments': custom_segments - } - ) + credit_card_charge_lineitem_object, _ = CreditCardChargeLineItem.objects.update_or_create( + credit_card_charge=credit_card_charge, + expense_id=lineitem.id, + defaults={ + 'account_id': account.destination_account.destination_id \ + if account and account.destination_account else None, + 'location_id': location_id, + 'class_id': class_id, + 'department_id': department_id, + 'customer_id': customer_id, + 'amount': lineitem.amount, + 'tax_item_id': get_tax_item_id_or_none(expense_group, general_mappings,lineitem), + 'tax_amount': lineitem.tax_amount, + 'billable': billable, + 'memo': get_expense_purpose(lineitem, category, configuration), + 'netsuite_custom_segments': custom_segments + } + ) - credit_card_charge_lineitem_objects.append(credit_card_charge_lineitem_object) + credit_card_charge_lineitem_objects.append(credit_card_charge_lineitem_object) - return credit_card_charge_lineitem_object + return credit_card_charge_lineitem_objects class ExpenseReport(models.Model): diff --git a/apps/netsuite/tasks.py b/apps/netsuite/tasks.py index da56208a..9ee71f70 100644 --- a/apps/netsuite/tasks.py +++ b/apps/netsuite/tasks.py @@ -551,7 +551,7 @@ def create_credit_card_charge(expense_group, task_log_id, last_export): with transaction.atomic(): credit_card_charge_object = CreditCardCharge.create_credit_card_charge(expense_group) - credit_card_charge_lineitems_object = CreditCardChargeLineItem.create_credit_card_charge_lineitem( + credit_card_charge_lineitems_objects = CreditCardChargeLineItem.create_credit_card_charge_lineitems( expense_group, configuration ) attachment_links = {} @@ -567,7 +567,7 @@ def create_credit_card_charge(expense_group, task_log_id, last_export): attachment_links[expense.expense_id] = attachment_link created_credit_card_charge = netsuite_connection.post_credit_card_charge( - credit_card_charge_object, credit_card_charge_lineitems_object, general_mappings, attachment_links, refund + credit_card_charge_object, credit_card_charge_lineitems_objects, general_mappings, attachment_links, refund ) worker_logger.info('Created Credit Card Charge with Expense Group %s successfully', expense_group.id) @@ -595,8 +595,9 @@ def create_credit_card_charge(expense_group, task_log_id, last_export): except Exception as e: logger.error('Error while updating expenses for expense_group_id: %s and posting accounting export summary %s', expense_group.id, e) - credit_card_charge_lineitems_object.netsuite_receipt_url = attachment_links.get(credit_card_charge_lineitems_object.expense.expense_id, None) - credit_card_charge_lineitems_object.save() + for credit_card_charge_lineitems_object in credit_card_charge_lineitems_objects: + credit_card_charge_lineitems_object.netsuite_receipt_url = attachment_links.get(credit_card_charge_lineitems_object.expense.expense_id, None) + credit_card_charge_lineitems_object.save() @handle_netsuite_exceptions(payment=False) diff --git a/apps/workspaces/apis/export_settings/serializers.py b/apps/workspaces/apis/export_settings/serializers.py index 9ae83898..f771876b 100644 --- a/apps/workspaces/apis/export_settings/serializers.py +++ b/apps/workspaces/apis/export_settings/serializers.py @@ -85,6 +85,7 @@ class ExpenseGroupSettingsSerializer(serializers.ModelSerializer): corporate_credit_card_expense_group_fields = serializers.ListField(allow_null=True, required=False) ccc_export_date_type = serializers.CharField(allow_null=True, allow_blank=True, required=False) ccc_expense_state = serializers.CharField(allow_null=True, allow_blank=True, required=False) + split_expense_grouping = serializers.CharField(allow_null=False, allow_blank=False, required=True) class Meta: model = ExpenseGroupSettings @@ -94,7 +95,8 @@ class Meta: 'expense_state', 'corporate_credit_card_expense_group_fields', 'ccc_export_date_type', - 'ccc_expense_state' + 'ccc_expense_state', + 'split_expense_grouping' ] diff --git a/scripts/sql/scripts/030-default-split-expense-grouping-for-old-orgs.sql b/scripts/sql/scripts/030-default-split-expense-grouping-for-old-orgs.sql new file mode 100644 index 00000000..89797023 --- /dev/null +++ b/scripts/sql/scripts/030-default-split-expense-grouping-for-old-orgs.sql @@ -0,0 +1,5 @@ +rollback; +begin; + +UPDATE expense_group_settings +SET split_expense_grouping = 'SINGLE_LINE_ITEM'; diff --git a/tests/sql_fixtures/reset_db_fixtures/reset_db.sql b/tests/sql_fixtures/reset_db_fixtures/reset_db.sql index 9f4b573e..3f526db9 100644 --- a/tests/sql_fixtures/reset_db_fixtures/reset_db.sql +++ b/tests/sql_fixtures/reset_db_fixtures/reset_db.sql @@ -2,7 +2,7 @@ -- PostgreSQL database dump -- --- Dumped from database version 15.9 (Debian 15.9-1.pgdg120+1) +-- Dumped from database version 15.7 (Debian 15.7-1.pgdg120+1) -- Dumped by pg_dump version 15.9 (Debian 15.9-1.pgdg120+1) SET statement_timeout = 0; @@ -959,7 +959,8 @@ CREATE TABLE public.expense_group_settings ( workspace_id integer NOT NULL, import_card_credits boolean NOT NULL, ccc_export_date_type character varying(100) NOT NULL, - ccc_expense_state character varying(100) + ccc_expense_state character varying(100), + split_expense_grouping character varying(100) NOT NULL ); @@ -1149,7 +1150,8 @@ CREATE TABLE public.expenses ( previous_export_state character varying(255), workspace_id integer, paid_on_fyle boolean NOT NULL, - is_posted_at_null boolean NOT NULL + is_posted_at_null boolean NOT NULL, + bank_transaction_id character varying(255) ); @@ -7992,6 +7994,7 @@ COPY public.django_migrations (id, app, name, applied) FROM stdin; 202 fyle 0034_expense_is_posted_at_null 2024-11-17 20:37:53.17847+00 203 tasks 0012_alter_tasklog_expense_group 2024-11-17 20:37:53.213044+00 204 workspaces 0040_alter_configuration_change_accounting_period 2024-11-18 04:28:36.094429+00 +205 fyle 0035_support_split_expense_grouping 2024-11-22 10:40:59.441051+00 \. @@ -11578,10 +11581,10 @@ COPY public.expense_filters (id, condition, operator, "values", rank, join_by, i -- Data for Name: expense_group_settings; Type: TABLE DATA; Schema: public; Owner: postgres -- -COPY public.expense_group_settings (id, reimbursable_expense_group_fields, corporate_credit_card_expense_group_fields, expense_state, reimbursable_export_date_type, created_at, updated_at, workspace_id, import_card_credits, ccc_export_date_type, ccc_expense_state) FROM stdin; -1 {employee_email,report_id,claim_number,fund_source} {employee_email,report_id,claim_number,fund_source} PAYMENT_PROCESSING current_date 2021-11-15 08:46:16.069944+00 2021-11-15 08:46:16.069986+00 1 f current_date PAID -2 {fund_source,employee_email,settlement_id,spent_at} {expense_id,fund_source,employee_email,settlement_id,spent_at} PAID spent_at 2021-11-16 04:16:57.847694+00 2021-11-16 07:34:26.302812+00 2 f spent_at PAID -74 {employee_email,report_id,claim_number,fund_source} {claim_number,employee_email,expense_id,report_id,fund_source} PAYMENT_PROCESSING last_spent_at 2021-12-03 11:00:33.637654+00 2021-12-03 11:04:00.206339+00 49 f last_spent_at PAID +COPY public.expense_group_settings (id, reimbursable_expense_group_fields, corporate_credit_card_expense_group_fields, expense_state, reimbursable_export_date_type, created_at, updated_at, workspace_id, import_card_credits, ccc_export_date_type, ccc_expense_state, split_expense_grouping) FROM stdin; +1 {employee_email,report_id,claim_number,fund_source} {employee_email,report_id,claim_number,fund_source} PAYMENT_PROCESSING current_date 2021-11-15 08:46:16.069944+00 2021-11-15 08:46:16.069986+00 1 f current_date PAID MULTIPLE_LINE_ITEM +2 {fund_source,employee_email,settlement_id,spent_at} {expense_id,fund_source,employee_email,settlement_id,spent_at} PAID spent_at 2021-11-16 04:16:57.847694+00 2021-11-16 07:34:26.302812+00 2 f spent_at PAID MULTIPLE_LINE_ITEM +74 {employee_email,report_id,claim_number,fund_source} {claim_number,employee_email,expense_id,report_id,fund_source} PAYMENT_PROCESSING last_spent_at 2021-12-03 11:00:33.637654+00 2021-12-03 11:04:00.206339+00 49 f last_spent_at PAID MULTIPLE_LINE_ITEM \. @@ -11633,15 +11636,15 @@ COPY public.expense_reports (id, account_id, entity_id, currency, department_id, -- Data for Name: expenses; Type: TABLE DATA; Schema: public; Owner: postgres -- -COPY public.expenses (id, employee_email, category, sub_category, project, expense_id, expense_number, claim_number, amount, currency, foreign_amount, foreign_currency, settlement_id, reimbursable, state, vendor, cost_center, purpose, report_id, spent_at, approved_at, expense_created_at, expense_updated_at, created_at, updated_at, fund_source, custom_properties, verified_at, paid_on_netsuite, billable, org_id, tax_amount, tax_group_id, project_id, file_ids, corporate_card_id, is_skipped, report_title, employee_name, posted_at, accounting_export_summary, previous_export_state, workspace_id, paid_on_fyle, is_posted_at_null) FROM stdin; -1 ashwin.t@fyle.in Accounts Payable Accounts Payable \N txjvDntD9ZXR E/2021/11/T/11 C/2021/11/R/5 50 USD \N \N set6GUp6tcEEp t PAYMENT_PROCESSING \N Treasury \N rpuN3bgphxbK 2021-11-15 00:00:00+00 2021-11-15 00:00:00+00 2021-11-15 10:27:53.649+00 2021-11-15 10:28:46.775+00 2021-11-15 10:29:07.597095+00 2021-11-15 10:29:07.597111+00 PERSONAL {"Team": "", "Class": "", "Klass": "", "Team 2": "", "Location": "", "Team Copy": "", "Tax Groups": "", "Departments": "", "User Dimension": "", "Location Entity": "", "Operating System": "", "System Operating": "", "User Dimension Copy": ""} \N f \N or79Cob97KSh \N \N \N \N \N f \N \N \N {} \N \N f f -2 ashwin.t@fyle.in Accounts Payable Accounts Payable \N txy6folbrG2j E/2021/11/T/12 C/2021/11/R/6 100 USD \N \N setNVTcPkZ6on f PAYMENT_PROCESSING Ashwin Vendor \N \N rpHLA9Dfp9hN 2021-11-15 00:00:00+00 2021-11-15 00:00:00+00 2021-11-15 13:11:22.304+00 2021-11-15 13:11:58.032+00 2021-11-15 13:12:12.250613+00 2021-11-15 13:12:12.250638+00 CCC {"Team": "", "Class": "", "Klass": "Klass", "Team 2": "", "Location": "", "Team Copy": "", "Tax Groups": "", "Departments": "", "User Dimension": "", "Location Entity": "", "Operating System": "", "System Operating": "", "User Dimension Copy": ""} \N f \N or79Cob97KSh \N \N \N \N \N f \N \N \N {} \N \N f f -3 ashwin.t@fyle.in Accounts Payable Accounts Payable \N txeLau9Rdu4X E/2021/11/T/1 C/2021/11/R/2 80 USD \N \N setqgvGQnsAya t PAYMENT_PROCESSING \N \N \N rpu5W0LYrk6e 2021-11-16 00:00:00+00 2021-11-16 00:00:00+00 2021-11-16 04:24:18.688+00 2021-11-16 04:25:21.996+00 2021-11-16 04:25:49.174565+00 2021-11-16 04:25:49.174584+00 PERSONAL {"Klass": "Klass", "Device Type": "", "Fyle Category": ""} \N f \N oraWFQlEpjbb 4.53 tg31j9m4PoEO \N \N \N f \N \N \N {} \N \N f f -4 ashwin.t@fyle.in Accounts Payable Accounts Payable \N txMLGb6Xy8m8 E/2021/11/T/2 C/2021/11/R/1 100 USD \N \N setqgvGQnsAya f PAYMENT_PROCESSING \N \N \N rprqDvARHUnv 2021-11-16 00:00:00+00 2021-11-16 00:00:00+00 2021-11-16 04:24:38.141+00 2021-11-16 04:25:21.996+00 2021-11-16 04:25:49.192351+00 2021-11-16 04:25:49.192367+00 CCC {"Device Type": "", "Fyle Category": ""} \N f \N oraWFQlEpjbb 16.67 tgSYjXsBCviv \N \N \N f \N \N \N {} \N \N f f -173 admin1@fyleforintacct.in Food Food Project 2 tx7A5QpesrV5 E/2021/12/T/1 C/2021/12/R/1 120 USD \N \N set15sMvtRIiS t PAYMENT_PROCESSING \N Sales and Cross \N rpXqCutQj85N 2021-12-03 00:00:00+00 2021-12-03 00:00:00+00 2021-12-03 10:58:30.076+00 2021-12-03 11:00:22.64+00 2021-12-03 11:26:58.685597+00 2021-12-03 11:26:58.685616+00 PERSONAL {} \N f \N orHe8CpW2hyN \N \N \N \N \N f \N \N \N {} \N \N f f -174 admin1@fyleforintacct.in Food Food Project 2 txcKVVELn1Vl E/2021/12/T/2 C/2021/12/R/1 130 USD \N \N set15sMvtRIiS f PAYMENT_PROCESSING \N Sales and Cross \N rpXqCutQj85N 2021-12-03 00:00:00+00 2021-12-03 00:00:00+00 2021-12-03 10:58:49.51+00 2021-12-03 11:00:22.64+00 2021-12-03 11:26:58.702183+00 2021-12-03 11:26:58.702209+00 CCC {} \N f \N orHe8CpW2hyN \N \N \N \N \N f \N \N \N {} \N \N f f -600 jhonsnoww@fyle.in Food Food Project 2 txcKVVELn1Vlkill E/2021/12/T/298 C/2021/12/R/198 130 USD \N \N set15sMvtRIiSkill f PAYMENT_PROCESSING \N Sales and Cross \N rpXqCutQj85Nkill 2021-12-03 00:00:00+00 2021-12-03 00:00:00+00 2021-12-03 10:58:49.51+00 2021-12-03 11:00:22.64+00 2021-12-03 11:26:58.702183+00 2021-12-03 11:26:58.702209+00 CCC {} \N f \N or79Cob97KSh \N \N \N \N \N t \N \N \N {} \N \N f f -601 jhonsnoww@fyle.in Food Food Project 2 txcKVVELn1Vlgon E/2021/12/T/299 C/2021/12/R/199 130 USD \N \N set15sMvtRIiSgon f PAYMENT_PROCESSING \N Sales and Cross \N rpXqCutQj85Ngon 2021-12-03 00:00:00+00 2021-12-03 00:00:00+00 2021-12-03 10:58:49.51+00 2021-12-03 11:00:22.64+00 2021-12-03 11:26:58.702183+00 2021-12-03 11:26:58.702209+00 CCC {} \N f \N or79Cob97KSh \N \N \N \N \N t \N \N \N {} \N \N f f +COPY public.expenses (id, employee_email, category, sub_category, project, expense_id, expense_number, claim_number, amount, currency, foreign_amount, foreign_currency, settlement_id, reimbursable, state, vendor, cost_center, purpose, report_id, spent_at, approved_at, expense_created_at, expense_updated_at, created_at, updated_at, fund_source, custom_properties, verified_at, paid_on_netsuite, billable, org_id, tax_amount, tax_group_id, project_id, file_ids, corporate_card_id, is_skipped, report_title, employee_name, posted_at, accounting_export_summary, previous_export_state, workspace_id, paid_on_fyle, is_posted_at_null, bank_transaction_id) FROM stdin; +1 ashwin.t@fyle.in Accounts Payable Accounts Payable \N txjvDntD9ZXR E/2021/11/T/11 C/2021/11/R/5 50 USD \N \N set6GUp6tcEEp t PAYMENT_PROCESSING \N Treasury \N rpuN3bgphxbK 2021-11-15 00:00:00+00 2021-11-15 00:00:00+00 2021-11-15 10:27:53.649+00 2021-11-15 10:28:46.775+00 2021-11-15 10:29:07.597095+00 2021-11-15 10:29:07.597111+00 PERSONAL {"Team": "", "Class": "", "Klass": "", "Team 2": "", "Location": "", "Team Copy": "", "Tax Groups": "", "Departments": "", "User Dimension": "", "Location Entity": "", "Operating System": "", "System Operating": "", "User Dimension Copy": ""} \N f \N or79Cob97KSh \N \N \N \N \N f \N \N \N {} \N \N f f \N +2 ashwin.t@fyle.in Accounts Payable Accounts Payable \N txy6folbrG2j E/2021/11/T/12 C/2021/11/R/6 100 USD \N \N setNVTcPkZ6on f PAYMENT_PROCESSING Ashwin Vendor \N \N rpHLA9Dfp9hN 2021-11-15 00:00:00+00 2021-11-15 00:00:00+00 2021-11-15 13:11:22.304+00 2021-11-15 13:11:58.032+00 2021-11-15 13:12:12.250613+00 2021-11-15 13:12:12.250638+00 CCC {"Team": "", "Class": "", "Klass": "Klass", "Team 2": "", "Location": "", "Team Copy": "", "Tax Groups": "", "Departments": "", "User Dimension": "", "Location Entity": "", "Operating System": "", "System Operating": "", "User Dimension Copy": ""} \N f \N or79Cob97KSh \N \N \N \N \N f \N \N \N {} \N \N f f \N +3 ashwin.t@fyle.in Accounts Payable Accounts Payable \N txeLau9Rdu4X E/2021/11/T/1 C/2021/11/R/2 80 USD \N \N setqgvGQnsAya t PAYMENT_PROCESSING \N \N \N rpu5W0LYrk6e 2021-11-16 00:00:00+00 2021-11-16 00:00:00+00 2021-11-16 04:24:18.688+00 2021-11-16 04:25:21.996+00 2021-11-16 04:25:49.174565+00 2021-11-16 04:25:49.174584+00 PERSONAL {"Klass": "Klass", "Device Type": "", "Fyle Category": ""} \N f \N oraWFQlEpjbb 4.53 tg31j9m4PoEO \N \N \N f \N \N \N {} \N \N f f \N +4 ashwin.t@fyle.in Accounts Payable Accounts Payable \N txMLGb6Xy8m8 E/2021/11/T/2 C/2021/11/R/1 100 USD \N \N setqgvGQnsAya f PAYMENT_PROCESSING \N \N \N rprqDvARHUnv 2021-11-16 00:00:00+00 2021-11-16 00:00:00+00 2021-11-16 04:24:38.141+00 2021-11-16 04:25:21.996+00 2021-11-16 04:25:49.192351+00 2021-11-16 04:25:49.192367+00 CCC {"Device Type": "", "Fyle Category": ""} \N f \N oraWFQlEpjbb 16.67 tgSYjXsBCviv \N \N \N f \N \N \N {} \N \N f f \N +173 admin1@fyleforintacct.in Food Food Project 2 tx7A5QpesrV5 E/2021/12/T/1 C/2021/12/R/1 120 USD \N \N set15sMvtRIiS t PAYMENT_PROCESSING \N Sales and Cross \N rpXqCutQj85N 2021-12-03 00:00:00+00 2021-12-03 00:00:00+00 2021-12-03 10:58:30.076+00 2021-12-03 11:00:22.64+00 2021-12-03 11:26:58.685597+00 2021-12-03 11:26:58.685616+00 PERSONAL {} \N f \N orHe8CpW2hyN \N \N \N \N \N f \N \N \N {} \N \N f f \N +174 admin1@fyleforintacct.in Food Food Project 2 txcKVVELn1Vl E/2021/12/T/2 C/2021/12/R/1 130 USD \N \N set15sMvtRIiS f PAYMENT_PROCESSING \N Sales and Cross \N rpXqCutQj85N 2021-12-03 00:00:00+00 2021-12-03 00:00:00+00 2021-12-03 10:58:49.51+00 2021-12-03 11:00:22.64+00 2021-12-03 11:26:58.702183+00 2021-12-03 11:26:58.702209+00 CCC {} \N f \N orHe8CpW2hyN \N \N \N \N \N f \N \N \N {} \N \N f f \N +600 jhonsnoww@fyle.in Food Food Project 2 txcKVVELn1Vlkill E/2021/12/T/298 C/2021/12/R/198 130 USD \N \N set15sMvtRIiSkill f PAYMENT_PROCESSING \N Sales and Cross \N rpXqCutQj85Nkill 2021-12-03 00:00:00+00 2021-12-03 00:00:00+00 2021-12-03 10:58:49.51+00 2021-12-03 11:00:22.64+00 2021-12-03 11:26:58.702183+00 2021-12-03 11:26:58.702209+00 CCC {} \N f \N or79Cob97KSh \N \N \N \N \N t \N \N \N {} \N \N f f \N +601 jhonsnoww@fyle.in Food Food Project 2 txcKVVELn1Vlgon E/2021/12/T/299 C/2021/12/R/199 130 USD \N \N set15sMvtRIiSgon f PAYMENT_PROCESSING \N Sales and Cross \N rpXqCutQj85Ngon 2021-12-03 00:00:00+00 2021-12-03 00:00:00+00 2021-12-03 10:58:49.51+00 2021-12-03 11:00:22.64+00 2021-12-03 11:26:58.702183+00 2021-12-03 11:26:58.702209+00 CCC {} \N f \N or79Cob97KSh \N \N \N \N \N t \N \N \N {} \N \N f f \N \. @@ -11903,7 +11906,7 @@ SELECT pg_catalog.setval('public.django_content_type_id_seq', 47, true); -- Name: django_migrations_id_seq; Type: SEQUENCE SET; Schema: public; Owner: postgres -- -SELECT pg_catalog.setval('public.django_migrations_id_seq', 203, true); +SELECT pg_catalog.setval('public.django_migrations_id_seq', 205, true); -- diff --git a/tests/test_fyle/conftest.py b/tests/test_fyle/conftest.py index 55b97681..cc753e66 100644 --- a/tests/test_fyle/conftest.py +++ b/tests/test_fyle/conftest.py @@ -27,3 +27,21 @@ def create_temp_workspace(db): import_card_credits=False ) + +@pytest.fixture +def update_config_for_split_expense_grouping(db): + def _update_config_for_split_expense_grouping(general_settings, expense_group_settings): + general_settings.corporate_credit_card_expenses_object = 'CREDIT CARD CHARGE' + general_settings.save() + expense_group_settings.split_expense_grouping = 'SINGLE_LINE_ITEM' + expense_group_settings.corporate_credit_card_expense_group_fields = [ + 'expense_id', + 'claim_number', + 'fund_source', + 'employee_email', + 'report_id', + 'spent_at', + 'report_id' + ] + expense_group_settings.save() + return _update_config_for_split_expense_grouping diff --git a/tests/test_fyle/fixtures.py b/tests/test_fyle/fixtures.py index d6bef867..0976095c 100644 --- a/tests/test_fyle/fixtures.py +++ b/tests/test_fyle/fixtures.py @@ -1,3 +1,6 @@ +import datetime + + data = { "raw_expense": { 'accounting_export_summary': { @@ -243,6 +246,7 @@ "expenses_spent_at":[ { 'id': '1234', + 'bank_transaction_id': 'btxnqmATEEBkJ4', 'employee_email': 'jhonsnow@fyle.in', 'category': 'Accounts Payable', 'sub_category': 'Accounts Payable', @@ -284,6 +288,7 @@ }, { 'id': '1235', + 'bank_transaction_id': 'btxnqmATEEBkJ4', 'employee_email': 'jhonsnow@fyle.in', 'category': 'Accounts Payable', 'sub_category': 'Accounts Payable', @@ -325,6 +330,7 @@ }, { 'id': '1236', + 'bank_transaction_id': 'btxnqmATEEBkJ4', 'employee_email': 'jhonsnow@fyle.in', 'category': 'Accounts Payable', 'sub_category': 'Accounts Payable', @@ -613,6 +619,7 @@ 'Vehicle Type': '', 'Fyle Categories': '', }, + 'bank_transaction_id': 'btxnqmATEEBkJ4', 'is_posted_at_null': True }, { @@ -655,6 +662,169 @@ 'Vehicle Type': '', 'Fyle Categories': '', }, + 'bank_transaction_id': 'btxnqmATEEBkJ4', + 'is_posted_at_null': True + }, + ], + "ccc_split_expenses": [ + { + 'amount': 1, + 'approved_at': datetime.datetime(2024, 11, 18, 11, 53, 1, 659759), + 'bank_transaction_id': 'btxnqmATEEBkJ4', + 'billable': None, + 'category': 'Food', + 'claim_number': 'C/2024/11/R/64', + 'corporate_card_id': 'baccHSRxUbjMHP', + 'cost_center': None, + 'currency': 'USD', + 'custom_properties': {'Custom Expense Field': None}, + 'employee_email': 'admin1@fylefordemocctransactions.org', + 'employee_name': 'Theresa Brown', + 'expense_created_at': '2024-11-18T11:41:10.776706+00:00', + 'expense_number': 'E/2024/11/T/522', + 'expense_updated_at': '2024-11-18T11:53:26.212150+00:00', + 'file_ids': [], + 'foreign_amount': None, + 'foreign_currency': None, + 'id': 'tx182ajKkiZ5', + 'org_id': 'orT192eaSf2q', + 'payment_number': 'P/2024/11/T/P/2024/11/R/44', + 'posted_at': None, + 'project': 'Project 9', + 'project_id': 336200, + 'purpose': None, + 'reimbursable': False, + 'report_id': 'rpQE6alQtnM1', + 'report_title': '4 same: multiple line items', + 'settled_at': None, + 'source_account_type': 'PERSONAL_CORPORATE_CREDIT_CARD_ACCOUNT', + 'spent_at': datetime.datetime(2024, 11, 15, 0, 0), + 'state': 'APPROVED', + 'sub_category': None, + 'tax_amount': None, + 'tax_group_id': None, + 'vendor': 'Dominos Pizza', + 'verified_at': None, + 'is_posted_at_null': True + }, + { + 'amount': 2, + 'approved_at': datetime.datetime(2024, 11, 18, 11, 53, 1, 659759), + 'bank_transaction_id': 'btxnqmATEEBkJ4', + 'billable': None, + 'category': 'Food', + 'claim_number': 'C/2024/11/R/64', + 'corporate_card_id': 'baccHSRxUbjMHP', + 'cost_center': None, + 'currency': 'USD', + 'custom_properties': {'Custom Expense Field': None}, + 'employee_email': 'admin1@fylefordemocctransactions.org', + 'employee_name': 'Theresa Brown', + 'expense_created_at': '2024-11-18T11:41:10.776706+00:00', + 'expense_number': 'E/2024/11/T/522', + 'expense_updated_at': '2024-11-18T11:53:26.212150+00:00', + 'file_ids': [], + 'foreign_amount': None, + 'foreign_currency': None, + 'id': 'txjb4Y0nTcEV', + 'org_id': 'orT192eaSf2q', + 'payment_number': 'P/2024/11/T/P/2024/11/R/44', + 'posted_at': None, + 'project': 'Project 9', + 'project_id': 336200, + 'purpose': None, + 'reimbursable': False, + 'report_id': 'rpQE6alQtnM1', + 'report_title': '4 same: multiple line items', + 'settled_at': None, + 'source_account_type': 'PERSONAL_CORPORATE_CREDIT_CARD_ACCOUNT', + 'spent_at': datetime.datetime(2024, 11, 15, 0, 0), + 'state': 'APPROVED', + 'sub_category': None, + 'tax_amount': None, + 'tax_group_id': None, + 'vendor': 'Dominos Pizza', + 'verified_at': None, + 'is_posted_at_null': True + }, + { + 'amount': 3, + 'approved_at': datetime.datetime(2024, 11, 18, 11, 53, 1, 659759), + 'bank_transaction_id': 'btxnqmATEEBkJ4', + 'billable': None, + 'category': 'Food', + 'claim_number': 'C/2024/11/R/64', + 'corporate_card_id': 'baccHSRxUbjMHP', + 'cost_center': None, + 'currency': 'USD', + 'custom_properties': {'Custom Expense Field': None}, + 'employee_email': 'admin1@fylefordemocctransactions.org', + 'employee_name': 'Theresa Brown', + 'expense_created_at': '2024-11-18T11:41:10.776706+00:00', + 'expense_number': 'E/2024/11/T/522', + 'expense_updated_at': '2024-11-18T11:53:26.212150+00:00', + 'file_ids': [], + 'foreign_amount': None, + 'foreign_currency': None, + 'id': 'txjkbAv4SC2w', + 'org_id': 'orT192eaSf2q', + 'payment_number': 'P/2024/11/T/P/2024/11/R/44', + 'posted_at': None, + 'project': 'Project 9', + 'project_id': 336200, + 'purpose': None, + 'reimbursable': False, + 'report_id': 'rpQE6alQtnM1', + 'report_title': '4 same: multiple line items', + 'settled_at': None, + 'source_account_type': 'PERSONAL_CORPORATE_CREDIT_CARD_ACCOUNT', + 'spent_at': datetime.datetime(2024, 11, 15, 0, 0), + 'state': 'APPROVED', + 'sub_category': None, + 'tax_amount': None, + 'tax_group_id': None, + 'vendor': 'Dominos Pizza', + 'verified_at': None, + 'is_posted_at_null': True + }, + { + 'amount': 4, + 'approved_at': datetime.datetime(2024, 11, 18, 11, 53, 1, 659759), + 'bank_transaction_id': 'btxnqmATEEBkJ4', + 'billable': None, + 'category': 'Food', + 'claim_number': 'C/2024/11/R/64', + 'corporate_card_id': 'baccHSRxUbjMHP', + 'cost_center': None, + 'currency': 'USD', + 'custom_properties': {'Custom Expense Field': None}, + 'employee_email': 'admin1@fylefordemocctransactions.org', + 'employee_name': 'Theresa Brown', + 'expense_created_at': '2024-11-18T11:41:10.776706+00:00', + 'expense_number': 'E/2024/11/T/522', + 'expense_updated_at': '2024-11-18T11:53:26.212150+00:00', + 'file_ids': [], + 'foreign_amount': None, + 'foreign_currency': None, + 'id': 'txrFFC6McUw0', + 'org_id': 'orT192eaSf2q', + 'payment_number': 'P/2024/11/T/P/2024/11/R/44', + 'posted_at': None, + 'project': 'Project 9', + 'project_id': 336200, + 'purpose': None, + 'reimbursable': False, + 'report_id': 'rpQE6alQtnM1', + 'report_title': '4 same: multiple line items', + 'settled_at': None, + 'source_account_type': 'PERSONAL_CORPORATE_CREDIT_CARD_ACCOUNT', + 'spent_at': datetime.datetime(2024, 11, 15, 0, 0), + 'state': 'APPROVED', + 'sub_category': None, + 'tax_amount': None, + 'tax_group_id': None, + 'vendor': 'Dominos Pizza', + 'verified_at': None, 'is_posted_at_null': True }, ], @@ -699,6 +869,7 @@ 'Vehicle Type': '', 'Fyle Categories': '', }, + 'bank_transaction_id': 'btxnqmATEEBkJ4', "is_posted_at_null": True }, { @@ -741,6 +912,7 @@ 'Vehicle Type': '', 'Fyle Categories': '', }, + 'bank_transaction_id': 'btxnqmATEEBkJ4', "is_posted_at_null": False }, ], @@ -841,7 +1013,8 @@ "import_card_credits": "False", "created_at": "2021-11-15T08:46:16.069944Z", "updated_at": "2021-11-15T08:46:16.069986Z", - "workspace": 1 + "workspace": 1, + "split_expense_grouping": "MULTIPLE_LINE_ITEM", }, "fyle_orgs": [ { @@ -945,7 +1118,8 @@ 'expense_state': 'PAYMENT_PROCESSING', 'reimbursable_export_date_type': 'spent_at', 'ccc_export_date_type': 'spent_at', - 'ccc_expense_state': 'PAID' + 'ccc_expense_state': 'PAID', + 'split_expense_grouping': 'MULTIPLE_LINE_ITEM', }, 'get_my_profile': { 'data': { diff --git a/tests/test_fyle/test_models.py b/tests/test_fyle/test_models.py index 0fc83fd9..da453493 100644 --- a/tests/test_fyle/test_models.py +++ b/tests/test_fyle/test_models.py @@ -8,7 +8,7 @@ from apps.workspaces.models import Configuration, Workspace from apps.tasks.models import TaskLog from apps.fyle.tasks import create_expense_groups -from .fixtures import data +from tests.test_fyle.fixtures import data @pytest.mark.django_db() @@ -129,3 +129,117 @@ def test_get_last_synced_at(db): assert reimbursement.workspace_id == 1 assert reimbursement.settlement_id == 'setqi0eM6HUgZ' assert reimbursement.state == 'COMPLETE' + + +def test_split_expense_grouping_with_no_bank_transaction_id(db, update_config_for_split_expense_grouping): + ''' + Test for grouping of 2 expenses with no bank transaction id + ''' + workspace_id = 1 + + # Update settings + configuration = Configuration.objects.get(workspace_id=workspace_id) + expense_group_settings = ExpenseGroupSettings.objects.get(workspace_id=workspace_id) + update_config_for_split_expense_grouping(configuration, expense_group_settings) + + # Get reference to expense objects + expenses = data['ccc_split_expenses'][:2] + for expense in expenses: + expense['bank_transaction_id'] = None + + Expense.create_expense_objects(expenses, workspace_id=workspace_id) + expense_objects = Expense.objects.filter(expense_id__in=[expense['id'] for expense in expenses]) + + assert len(expense_objects) == 2, f'Expected 2 expenses, got {len(expense_objects)}' + + # Test for SINGLE_LINE_ITEM split expense grouping + ExpenseGroup.create_expense_groups_by_report_id_fund_source(expense_objects, configuration, workspace_id) + groups = ExpenseGroup.objects.filter(expenses__expense_id__in=[expense['id'] for expense in expenses]) + assert len(groups) == 2, f'Expected 2 groups, got {len(groups)}' + old_count = len(groups) + + # Test for MULTIPLE_LINE_ITEM split expense grouping + expense_group_settings.split_expense_grouping = 'MULTIPLE_LINE_ITEM' + expense_group_settings.save() + + ExpenseGroup.create_expense_groups_by_report_id_fund_source(expense_objects, configuration, workspace_id) + groups = ExpenseGroup.objects.filter(expenses__expense_id__in=[expense['id'] for expense in expenses]).distinct() + assert len(groups) - old_count == 2, f'Expected 2 groups, got {len(groups) - old_count}' + + +def test_split_expense_grouping_with_same_and_different_ids(db, update_config_for_split_expense_grouping): + ''' + Test for grouping of 2 expenses with the same bank transaction id, + and one expense with a different bank transaction id + ''' + workspace_id = 1 + + # Update settings + configuration = Configuration.objects.get(workspace_id=workspace_id) + expense_group_settings = ExpenseGroupSettings.objects.get(workspace_id=workspace_id) + update_config_for_split_expense_grouping(configuration, expense_group_settings) + + # Get reference to expense objects + expenses = data['ccc_split_expenses'][:3] + expenses[0]['bank_transaction_id'] = 'sample_1' + expenses[1]['bank_transaction_id'] = 'sample_1' + expenses[2]['bank_transaction_id'] = 'sample_2' + + Expense.create_expense_objects(expenses, workspace_id=workspace_id) + expense_objects = Expense.objects.filter(expense_id__in=[expense['id'] for expense in expenses]) + + assert len(expense_objects) == 3, f'Expected 3 expenses, got {len(expense_objects)}' + + # Test for SINGLE_LINE_ITEM split expense grouping + ExpenseGroup.create_expense_groups_by_report_id_fund_source(expense_objects, configuration, workspace_id) + groups = ExpenseGroup.objects.filter(expenses__expense_id__in=[expense['id'] for expense in expenses]) + assert len(groups) == 3, f'Expected 3 groups, got {len(groups)}' + old_count = len(groups) + + # Test for MULTIPLE_LINE_ITEM split expense grouping + expense_group_settings.split_expense_grouping = 'MULTIPLE_LINE_ITEM' + expense_group_settings.save() + + ExpenseGroup.create_expense_groups_by_report_id_fund_source(expense_objects, configuration, workspace_id) + groups = ExpenseGroup.objects.filter(expenses__expense_id__in=[expense['id'] for expense in expenses]).distinct() + + assert len(groups) - old_count == 2, f'Expected 2 groups, got {len(groups) - old_count}' + + +def test_split_expense_grouping_pairs_of_same_ids(db, update_config_for_split_expense_grouping): + ''' + Test for grouping of 2 pairs of expenses with the same bank transaction ids + ''' + workspace_id = 1 + + # Update settings + configuration = Configuration.objects.get(workspace_id=workspace_id) + expense_group_settings = ExpenseGroupSettings.objects.get(workspace_id=workspace_id) + update_config_for_split_expense_grouping(configuration, expense_group_settings) + + # Get reference to expense objects + expenses = data['ccc_split_expenses'][:4] + expenses[0]['bank_transaction_id'] = 'sample_1' + expenses[1]['bank_transaction_id'] = 'sample_1' + expenses[2]['bank_transaction_id'] = 'sample_2' + expenses[3]['bank_transaction_id'] = 'sample_2' + + Expense.create_expense_objects(expenses, workspace_id=workspace_id) + expense_objects = Expense.objects.filter(expense_id__in=[expense['id'] for expense in expenses]) + + assert len(expense_objects) == 4, f'Expected 4 expenses, got {len(expense_objects)}' + + # Test for SINGLE_LINE_ITEM split expense grouping + ExpenseGroup.create_expense_groups_by_report_id_fund_source(expense_objects, configuration, workspace_id) + groups = ExpenseGroup.objects.filter(expenses__expense_id__in=[expense['id'] for expense in expenses]) + assert len(groups) == 4, f'Expected 4 groups, got {len(groups)}' + old_count = len(groups) + + # Test for MULTIPLE_LINE_ITEM split expense grouping + expense_group_settings.split_expense_grouping = 'MULTIPLE_LINE_ITEM' + expense_group_settings.save() + + ExpenseGroup.create_expense_groups_by_report_id_fund_source(expense_objects, configuration, workspace_id) + groups = ExpenseGroup.objects.filter(expenses__expense_id__in=[expense['id'] for expense in expenses]).distinct() + + assert len(groups) - old_count== 2, f'Expected 2 groups, got {len(groups) - old_count}' diff --git a/tests/test_netsuite/conftest.py b/tests/test_netsuite/conftest.py index 6995201e..0aff3b46 100644 --- a/tests/test_netsuite/conftest.py +++ b/tests/test_netsuite/conftest.py @@ -269,7 +269,7 @@ def create_credit_card_charge(db, add_netsuite_credentials, add_fyle_credentials configuration = Configuration.objects.get(workspace_id=1) credit_card_charge_object = CreditCardCharge.create_credit_card_charge(expense_group) - credit_card_charge_lineitems_object = CreditCardChargeLineItem.create_credit_card_charge_lineitem( + credit_card_charge_lineitems_object = CreditCardChargeLineItem.create_credit_card_charge_lineitems( expense_group, configuration ) diff --git a/tests/test_netsuite/test_connector.py b/tests/test_netsuite/test_connector.py index 47d4217b..d1102c4c 100644 --- a/tests/test_netsuite/test_connector.py +++ b/tests/test_netsuite/test_connector.py @@ -289,8 +289,8 @@ def test_contruct_credit_card_charge(create_credit_card_charge): general_mapping = GeneralMapping.objects.get(workspace_id=49) - credit_card_charge, credit_card_charge_lineitem = create_credit_card_charge - credit_card_charge_object = netsuite_connection._NetSuiteConnector__construct_credit_card_charge(credit_card_charge, credit_card_charge_lineitem, general_mapping, []) + credit_card_charge, credit_card_charge_lineitems = create_credit_card_charge + credit_card_charge_object = netsuite_connection._NetSuiteConnector__construct_credit_card_charge(credit_card_charge, credit_card_charge_lineitems, general_mapping, []) credit_card_charge_object['tranDate'] = data['credit_card_charge'][0]['tranDate'] credit_card_charge_object['tranid'] = data['credit_card_charge'][0]['tranid'] @@ -304,12 +304,14 @@ def test_contruct_credit_card_charge_with_tax_balancing(create_credit_card_charg general_mapping = GeneralMapping.objects.get(workspace_id=49) # without tax balancing - credit_card_charge, credit_card_charge_lineitem = create_credit_card_charge - credit_card_charge_lineitem.amount = 100 - credit_card_charge_lineitem.tax_amount = 3 - credit_card_charge_lineitem.tax_item_id = '103578' + credit_card_charge, credit_card_charge_lineitems = create_credit_card_charge - credit_card_charge_object = netsuite_connection._NetSuiteConnector__construct_credit_card_charge(credit_card_charge, credit_card_charge_lineitem, general_mapping, []) + item = credit_card_charge_lineitems[0] + item.amount = 100 + item.tax_amount = 3 + item.tax_item_id = '103578' + + credit_card_charge_object = netsuite_connection._NetSuiteConnector__construct_credit_card_charge(credit_card_charge, credit_card_charge_lineitems, general_mapping, []) assert len(credit_card_charge_object['expenses']) == 1 assert credit_card_charge_object['expenses'][0]['amount'] == 97 @@ -319,7 +321,7 @@ def test_contruct_credit_card_charge_with_tax_balancing(create_credit_card_charg general_mapping.is_tax_balancing_enabled = True general_mapping.save() - credit_card_charge_object = netsuite_connection._NetSuiteConnector__construct_credit_card_charge(credit_card_charge, credit_card_charge_lineitem, general_mapping, []) + credit_card_charge_object = netsuite_connection._NetSuiteConnector__construct_credit_card_charge(credit_card_charge, credit_card_charge_lineitems, general_mapping, []) assert len(credit_card_charge_object['expenses']) == 2 assert credit_card_charge_object['expenses'][0]['amount'] == 60 @@ -328,11 +330,11 @@ def test_contruct_credit_card_charge_with_tax_balancing(create_credit_card_charg assert credit_card_charge_object['expenses'][1]['taxCode']['internalId'] == general_mapping.default_tax_code_id # with tax balancing enabled and right tax amount - credit_card_charge_lineitem.amount = 100 - credit_card_charge_lineitem.tax_amount = 4.76 - credit_card_charge_lineitem.tax_item_id = '103578' + item.amount = 100 + item.tax_amount = 4.76 + item.tax_item_id = '103578' - credit_card_charge_object = netsuite_connection._NetSuiteConnector__construct_credit_card_charge(credit_card_charge, credit_card_charge_lineitem, general_mapping, []) + credit_card_charge_object = netsuite_connection._NetSuiteConnector__construct_credit_card_charge(credit_card_charge, credit_card_charge_lineitems, general_mapping, []) assert len(credit_card_charge_object['expenses']) == 1 assert credit_card_charge_object['expenses'][0]['amount'] == 95.24 diff --git a/tests/test_netsuite/test_models.py b/tests/test_netsuite/test_models.py index baa4b7bf..d9b91b54 100644 --- a/tests/test_netsuite/test_models.py +++ b/tests/test_netsuite/test_models.py @@ -340,11 +340,12 @@ def test_create_credit_card_charge(db): expense_group = ExpenseGroup.objects.get(id=4) credit_card = CreditCardCharge.create_credit_card_charge(expense_group) configuration = Configuration.objects.get(workspace_id=2) - credit_card_charge_lineitem = CreditCardChargeLineItem.create_credit_card_charge_lineitem(expense_group, configuration) + credit_card_charge_lineitems = CreditCardChargeLineItem.create_credit_card_charge_lineitems(expense_group, configuration) - assert credit_card_charge_lineitem.amount == 100.00 - assert credit_card_charge_lineitem.memo == 'ashwin.t@fyle.in - Accounts Payable - 2021-11-16 - C/2021/11/R/1 - ' - assert credit_card_charge_lineitem.billable == False + line = credit_card_charge_lineitems[0] + assert line.amount == 100.00 + assert line.memo == 'ashwin.t@fyle.in - Accounts Payable - 2021-11-16 - C/2021/11/R/1 - ' + assert line.billable == False assert credit_card.currency == '1' assert credit_card.transaction_date <= datetime.now().strftime('%Y-%m-%dT%H:%M:%S') @@ -363,11 +364,12 @@ def test_create_credit_card_charge(db): credit_card = CreditCardCharge.create_credit_card_charge(expense_group) configuration = Configuration.objects.get(workspace_id=1) - credit_card_charge_lineitem = CreditCardChargeLineItem.create_credit_card_charge_lineitem(expense_group, configuration) + credit_card_charge_lineitems = CreditCardChargeLineItem.create_credit_card_charge_lineitems(expense_group, configuration) - assert credit_card_charge_lineitem.amount == 100.00 - assert credit_card_charge_lineitem.memo == 'ashwin.t@fyle.in - Accounts Payable - 2021-11-15 - C/2021/11/R/6 - ' - assert credit_card_charge_lineitem.billable == False + line = credit_card_charge_lineitems[0] + assert line.amount == 100.00 + assert line.memo == 'ashwin.t@fyle.in - Accounts Payable - 2021-11-15 - C/2021/11/R/6 - ' + assert line.billable == False assert credit_card.currency == '1' assert credit_card.transaction_date <= datetime.now().strftime('%Y-%m-%dT%H:%M:%S') diff --git a/tests/test_workspaces/test_apis/test_export_settings/fixtures.py b/tests/test_workspaces/test_apis/test_export_settings/fixtures.py index 99a943a2..1761c6db 100644 --- a/tests/test_workspaces/test_apis/test_export_settings/fixtures.py +++ b/tests/test_workspaces/test_apis/test_export_settings/fixtures.py @@ -26,7 +26,8 @@ 'expense_id' ], 'ccc_export_date_type': 'spent_at', - 'ccc_expense_state': 'PAID' + 'ccc_expense_state': 'PAID', + 'split_expense_grouping': 'MULTIPLE_LINE_ITEM' }, 'general_mappings': { 'reimbursable_account': { @@ -74,7 +75,8 @@ 'expense_id' ], 'ccc_export_date_type': 'spent_at', - 'ccc_expense_state': 'PAID' + 'ccc_expense_state': 'PAID', + 'split_expense_grouping': 'MULTIPLE_LINE_ITEM' }, 'general_mappings': { 'reimbursable_account': {