Skip to content

Commit

Permalink
feat: support split expense grouping (#681)
Browse files Browse the repository at this point in the history
* 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 c0309e8)
  • Loading branch information
JustARatherRidiculouslyLongUsername committed Nov 26, 2024
1 parent a2bb92a commit e1166da
Show file tree
Hide file tree
Showing 15 changed files with 558 additions and 157 deletions.
24 changes: 24 additions & 0 deletions apps/fyle/migrations/0035_support_split_expense_grouping.py
Original file line number Diff line number Diff line change
@@ -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),
),
]
62 changes: 57 additions & 5 deletions apps/fyle/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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'
]


Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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'],
Expand Down Expand Up @@ -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):
"""
Expand All @@ -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'
Expand Down Expand Up @@ -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']
}
)

Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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))
Expand All @@ -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(
Expand Down
76 changes: 39 additions & 37 deletions apps/netsuite/connector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1664,15 +1663,15 @@ 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
}

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
Expand All @@ -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))

Expand Down
Loading

0 comments on commit e1166da

Please sign in to comment.