Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Journal Entry Export #52

Merged
merged 17 commits into from
Jan 17, 2024
78 changes: 78 additions & 0 deletions apps/accounting_exports/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Generated by Django 4.1.2 on 2024-01-09 03:44

import django.contrib.postgres.fields
from django.db import migrations, models
import django.db.models.deletion
import ms_business_central_api.models.fields


class Migration(migrations.Migration):

initial = True

dependencies = [
('workspaces', '0002_alter_workspace_ms_business_central_accounts_last_synced_at_and_more'),
('fyle', '0001_initial'),
('fyle_accounting_mappings', '0024_auto_20230922_0819'),
]

operations = [
migrations.CreateModel(
name='AccountingExport',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, help_text='Created at datetime')),
('updated_at', models.DateTimeField(auto_now=True, help_text='Updated at datetime')),
('id', models.AutoField(primary_key=True, serialize=False)),
('type', ms_business_central_api.models.fields.StringOptionsField(choices=[('PURCHASE_INVOICES', 'PURCHASE_INVOICES'), ('JOURNAL_ENTRY', 'JOURNAL_ENTRY'), ('FETCHING_REIMBURSABLE_EXPENSES', 'FETCHING_REIMBURSABLE_EXPENSES'), ('FETCHING_CREDIT_CARD_EXPENENSES', 'FETCHING_CREDIT_CARD_EXPENENSES')], default='', help_text='Task type', max_length=255, null=True)),
('fund_source', ms_business_central_api.models.fields.StringNotNullField(help_text='Expense fund source', max_length=255)),
('mapping_errors', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=255), blank=True, help_text='Mapping errors', null=True, size=None)),
('task_id', ms_business_central_api.models.fields.StringNullField(help_text='Fyle Jobs task reference', max_length=255, null=True)),
('description', ms_business_central_api.models.fields.CustomJsonField(default=list, help_text='Description', null=True)),
('status', ms_business_central_api.models.fields.StringNotNullField(help_text='Task Status', max_length=255)),
('detail', ms_business_central_api.models.fields.CustomJsonField(default=list, help_text='Task Response', null=True)),
('business_central_errors', ms_business_central_api.models.fields.CustomJsonField(default=list, help_text='Business Central Errors', null=True)),
('exported_at', ms_business_central_api.models.fields.CustomDateTimeField(help_text='time of export', null=True)),
('expenses', models.ManyToManyField(help_text='Expenses under this Expense Group', to='fyle.expense')),
('workspace', models.ForeignKey(help_text='Reference to Workspace model', on_delete=django.db.models.deletion.PROTECT, to='workspaces.workspace')),
],
options={
'db_table': 'accounting_exports',
},
),
migrations.CreateModel(
name='Error',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, help_text='Created at datetime')),
('updated_at', models.DateTimeField(auto_now=True, help_text='Updated at datetime')),
('id', models.AutoField(primary_key=True, serialize=False)),
('type', ms_business_central_api.models.fields.StringOptionsField(choices=[('EMPLOYEE_MAPPING', 'EMPLOYEE_MAPPING'), ('CATEGORY_MAPPING', 'CATEGORY_MAPPING'), ('BUSINESS_CENTRAL_ERROR', 'BUSINESS_CENTRAL_ERROR')], default='', help_text='Error type', max_length=50, null=True)),
('is_resolved', ms_business_central_api.models.fields.BooleanFalseField(default=True, help_text='Is resolved')),
('error_title', ms_business_central_api.models.fields.StringNotNullField(help_text='Error title', max_length=255)),
('error_detail', ms_business_central_api.models.fields.TextNotNullField(help_text='Error detail')),
('accounting_export', models.ForeignKey(help_text='Reference to Expense group', null=True, on_delete=django.db.models.deletion.PROTECT, to='accounting_exports.accountingexport')),
('expense_attribute', models.OneToOneField(help_text='Reference to Expense Attribute', null=True, on_delete=django.db.models.deletion.PROTECT, to='fyle_accounting_mappings.expenseattribute')),
('workspace', models.ForeignKey(help_text='Reference to Workspace model', on_delete=django.db.models.deletion.PROTECT, to='workspaces.workspace')),
],
options={
'db_table': 'errors',
},
),
migrations.CreateModel(
name='AccountingExportSummary',
fields=[
('created_at', models.DateTimeField(auto_now_add=True, help_text='Created at datetime')),
('updated_at', models.DateTimeField(auto_now=True, help_text='Updated at datetime')),
('id', models.AutoField(primary_key=True, serialize=False)),
('last_exported_at', ms_business_central_api.models.fields.CustomDateTimeField(help_text='Last exported at datetime', null=True)),
('next_export_at', ms_business_central_api.models.fields.CustomDateTimeField(help_text='next export datetime', null=True)),
('export_mode', ms_business_central_api.models.fields.StringOptionsField(choices=[('MANUAL', 'MANUAL'), ('AUTO', 'AUTO')], default='', help_text='Export mode', max_length=255, null=True)),
('total_accounting_export_count', ms_business_central_api.models.fields.IntegerNullField(help_text='Total count of accounting export exported', null=True)),
('successful_accounting_export_count', ms_business_central_api.models.fields.IntegerNullField(help_text='count of successful accounting export', null=True)),
('failed_accounting_export_count', ms_business_central_api.models.fields.IntegerNullField(help_text='count of failed accounting export', null=True)),
('workspace', models.OneToOneField(help_text='Reference to Workspace model', on_delete=django.db.models.deletion.PROTECT, to='workspaces.workspace')),
],
options={
'db_table': 'accounting_export_summary',
},
),
]
8 changes: 4 additions & 4 deletions apps/accounting_exports/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@
)

TYPE_CHOICES = (
('INVOICES', 'INVOICES'),
('DIRECT_COST', 'DIRECT_COST'),
('PURCHASE_INVOICES', 'PURCHASE_INVOICES'),
('JOURNAL_ENTRY', 'JOURNAL_ENTRY'),
('FETCHING_REIMBURSABLE_EXPENSES', 'FETCHING_REIMBURSABLE_EXPENSES'),
('FETCHING_CREDIT_CARD_EXPENENSES', 'FETCHING_CREDIT_CARD_EXPENENSES')
)
Expand Down Expand Up @@ -123,9 +123,9 @@ def create_accounting_export(expense_objects: List[Expense], fund_source: str, w
date_field = getattr(export_setting, f"{fund_source_map.get(fund_source)}_expense_date", None)

# Calculate and assign 'last_spent_at' based on the chosen date field
if date_field == 'last_spent_at':
if date_field == 'LAST_SPENT_AT':
latest_expense = Expense.objects.filter(id__in=accounting_export['expense_ids']).order_by('-spent_at').first()
accounting_export['last_spent_at'] = latest_expense.spent_at if latest_expense else None
accounting_export['LAST_SPENT_AT'] = latest_expense.spent_at if latest_expense else None

# Store expense IDs and remove unnecessary keys
expense_ids = accounting_export['expense_ids']
Expand Down
21 changes: 21 additions & 0 deletions apps/business_central/actions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from django.db.models import Q

from apps.accounting_exports.models import AccountingExport, AccountingExportSummary


def update_accounting_export_summary(workspace_id):
accounting_export_summary = AccountingExportSummary.objects.get(workspace_id=workspace_id)

failed_exports = AccountingExport.objects.filter(~Q(type__in=['FETCHING_REIMBURSABLE_EXPENSES', 'FETCHING_CREDIT_CARD_EXPENSES']), workspace_id=workspace_id, status__in=['FAILED', 'FATAL']).count()

successful_exports = AccountingExport.objects.filter(
~Q(type__in=['FETCHING_REIMBURSABLE_EXPENSES', 'FETCHING_CREDIT_CARD_EXPENSES']),
workspace_id=workspace_id, status='COMPLETE',
).count()

accounting_export_summary.failed_accounting_exports_count = failed_exports
accounting_export_summary.successful_accounting_exports_count = successful_exports
accounting_export_summary.total_accounting_exports_count = failed_exports + successful_exports
accounting_export_summary.save()

return accounting_export_summary
84 changes: 84 additions & 0 deletions apps/business_central/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@

import logging
import traceback

from dynamics.exceptions.dynamics_exceptions import WrongParamsError

from apps.accounting_exports.models import AccountingExport, Error
from apps.business_central.actions import update_accounting_export_summary
from apps.workspaces.models import BusinessCentralCredentials, FyleCredential
from ms_business_central_api.exceptions import BulkError

logger = logging.getLogger(__name__)
logger.level = logging.INFO


def handle_business_central_error(exception, accounting_export: AccountingExport, export_type: str):
logger.info(exception.response)

business_central_error = exception.response
error_msg = 'Failed to create {0}'.format(export_type)

Error.objects.update_or_create(workspace_id=accounting_export.workspace_id, accounting_export=accounting_export, defaults={'error_title': error_msg, 'type': 'BUSINESS_CENTRAL_ERROR', 'error_detail': business_central_error, 'is_resolved': False})

accounting_export.status = 'FAILED'
accounting_export.detail = None
accounting_export.business_central_errors = business_central_error
accounting_export.save()


def set_last_export_details(accounting_export: AccountingExport, status, message):
ruuushhh marked this conversation as resolved.
Show resolved Hide resolved
detail = {'accounting_export_id': accounting_export.id, 'message': '{0}'.format(message)}
accounting_export.status = status
accounting_export.detail = detail

accounting_export.save()


def handle_business_central_exceptions():
def decorator(func):
def new_fn(*args):

accounting_export = args[0]

try:
return func(*args)
except (FyleCredential.DoesNotExist):
logger.info('Fyle credentials not found %s', accounting_export.workspace_id)
accounting_export.detail = {'message': 'Fyle credentials do not exist in workspace'}
accounting_export.status = 'FAILED'

accounting_export.save()

except BusinessCentralCredentials.DoesNotExist:
logger.info('Sage300 Account not connected / token expired for workspace_id %s / accounting export %s', accounting_export.workspace_id, accounting_export.id)
detail = {'accounting_export_id': accounting_export.id, 'message': 'Sage300 Account not connected / token expired'}
accounting_export.status = 'FAILED'
accounting_export.detail = detail

accounting_export.save()

except WrongParamsError as exception:
handle_business_central_error(exception, accounting_export, 'Purchase Invoice')

except BulkError as exception:
logger.info(exception.response)
detail = exception.response
accounting_export.status = 'FAILED'
accounting_export.detail = detail

accounting_export.save()

except Exception as error:
error = traceback.format_exc()
accounting_export.detail = {'error': error}
accounting_export.status = 'FATAL'

accounting_export.save()
logger.error('Something unexpected happened workspace_id: %s %s', accounting_export.workspace_id, accounting_export.detail)

update_accounting_export_summary(accounting_export.workspace_id)

return new_fn

return decorator
Empty file.
79 changes: 79 additions & 0 deletions apps/business_central/exports/accounting_export.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import logging
from datetime import datetime

from django.db import transaction

from apps.accounting_exports.models import AccountingExport
from apps.business_central.exports.helpers import resolve_errors_for_exported_accounting_export, validate_accounting_export
from apps.workspaces.models import AdvancedSetting, ExportSetting

logger = logging.getLogger(__name__)
logger.level = logging.INFO


class AccountingDataExporter:
"""
Base class for exporting accounting data to an external accounting system.
Subclasses should implement the 'post' method for posting data.
"""

def __init__(self):
self.body_model = None
self.lineitem_model = None

def post(self, workspace_id, body, lineitems = None):
"""
Implement this method to post data to the external accounting system.
"""
raise NotImplementedError("Please implement this method")

def create_business_central_object(self, accounting_export: AccountingExport):
"""
Create a accounting expense in the external accounting system.

Args:
accounting_export (AccountingExport): The accounting export object.

Raises:
NotImplementedError: If the method is not implemented in the subclass.
"""
# Retrieve advance settings for the current workspace
advance_settings = AdvancedSetting.objects.filter(workspace_id=accounting_export.workspace_id).first()

export_settings = ExportSetting.objects.filter(workspace_id=accounting_export.workspace_id).first()

# Check and update the status of the accounting export
if accounting_export.status not in ['IN_PROGRESS', 'COMPLETE']:
accounting_export.status = 'IN_PROGRESS'
accounting_export.save()
else:
# If the status is already 'IN_PROGRESS' or 'COMPLETE', return without further processing
return
try:
validate_accounting_export(accounting_export)
with transaction.atomic():
# Create or update the main body of the accounting object
body_model_object = self.body_model.create_or_update_object(accounting_export, advance_settings, export_settings)

# Create or update line items for the accounting object
lineitems_model_objects = None
if self.lineitem_model:
lineitems_model_objects = self.lineitem_model.create_or_update_object(
accounting_export, advance_settings
)

# Post the data to the external accounting system
created_object = self.post(accounting_export, body_model_object, lineitems_model_objects)

# Update the accounting export details
detail = created_object

accounting_export.detail = detail
accounting_export.business_central_errors = None
accounting_export.exported_at = datetime.now()
accounting_export.status = 'COMPLETE'
accounting_export.save()
resolve_errors_for_exported_accounting_export(accounting_export)
except Exception as e:
logger.error(e)
raise e
104 changes: 104 additions & 0 deletions apps/business_central/exports/base_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
from datetime import datetime

from django.db import models
from django.db.models import Sum

from apps.accounting_exports.models import AccountingExport
from apps.fyle.models import Expense
from apps.workspaces.models import AdvancedSetting, FyleCredential, Workspace


class BaseExportModel(models.Model):
"""
Base Model for Business Central Export
"""
created_at = models.DateTimeField(auto_now_add=True, help_text='Created at')
updated_at = models.DateTimeField(auto_now=True, help_text='Updated at')
workspace = models.ForeignKey(Workspace, on_delete=models.PROTECT, help_text='Reference to Workspace model')

class Meta:
abstract = True

def get_expense_comment(workspace_id, lineitem: Expense, category: str, advance_setting: AdvancedSetting) -> str:
workspace = Workspace.objects.get(id=workspace_id)
org_id = workspace.org_id

fyle_credentials = FyleCredential.objects.get(workspace_id=workspace_id)
cluster_domain = fyle_credentials.cluster_domain
workspace.cluster_domain = cluster_domain
workspace.save()

expense_link = '{0}/app/main/#/enterprise/view_expense/{1}?org_id={2}'.format(
cluster_domain, lineitem.expense_id, org_id
)

memo_structure = advance_setting.expense_memo_structure

details = {
'employee_email': lineitem.employee_email,
'merchant': '{0}'.format(lineitem.vendor) if lineitem.vendor else '',
'category': '{0}'.format(category) if lineitem.category else '',
'purpose': '{0}'.format(lineitem.purpose) if lineitem.purpose else '',
'report_number': '{0}'.format(lineitem.claim_number),
'spent_on': '{0}'.format(lineitem.spent_at.date()) if lineitem.spent_at else '',
'expense_link': expense_link
}

purpose = ''

for id, field in enumerate(memo_structure):
if field in details:
purpose += details[field]
if id + 1 != len(memo_structure):
purpose = '{0} - '.format(purpose)

return purpose
Comment on lines +23 to +56
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The get_expense_comment method constructs a comment for an expense based on the expense_memo_structure from AdvancedSetting. It dynamically builds the comment by iterating over the memo structure and appending details from the expense. The method correctly handles the case where certain details may not be present. However, there is a bug in the loop that concatenates the fields; it appends a hyphen after every field, which will result in an incorrect comment format.

- purpose = '{0} - '.format(purpose)
+ if id + 1 != len(memo_structure):
+     purpose += ' - '

Committable suggestion

IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation.

Suggested change
def get_expense_comment(workspace_id, lineitem: Expense, category: str, advance_setting: AdvancedSetting) -> str:
workspace = Workspace.objects.get(id=workspace_id)
org_id = workspace.org_id
fyle_credentials = FyleCredential.objects.get(workspace_id=workspace_id)
cluster_domain = fyle_credentials.cluster_domain
workspace.cluster_domain = cluster_domain
workspace.save()
expense_link = '{0}/app/main/#/enterprise/view_expense/{1}?org_id={2}'.format(
cluster_domain, lineitem.expense_id, org_id
)
memo_structure = advance_setting.expense_memo_structure
details = {
'employee_email': lineitem.employee_email,
'merchant': '{0}'.format(lineitem.vendor) if lineitem.vendor else '',
'category': '{0}'.format(category) if lineitem.category else '',
'purpose': '{0}'.format(lineitem.purpose) if lineitem.purpose else '',
'report_number': '{0}'.format(lineitem.claim_number),
'spent_on': '{0}'.format(lineitem.spent_at.date()) if lineitem.spent_at else '',
'expense_link': expense_link
}
purpose = ''
for id, field in enumerate(memo_structure):
if field in details:
purpose += details[field]
if id + 1 != len(memo_structure):
purpose = '{0} - '.format(purpose)
return purpose
def get_expense_comment(workspace_id, lineitem: Expense, category: str, advance_setting: AdvancedSetting) -> str:
workspace = Workspace.objects.get(id=workspace_id)
org_id = workspace.org_id
fyle_credentials = FyleCredential.objects.get(workspace_id=workspace_id)
cluster_domain = fyle_credentials.cluster_domain
workspace.cluster_domain = cluster_domain
workspace.save()
expense_link = '{0}/app/main/#/enterprise/view_expense/{1}?org_id={2}'.format(
cluster_domain, lineitem.expense_id, org_id
)
memo_structure = advance_setting.expense_memo_structure
details = {
'employee_email': lineitem.employee_email,
'merchant': '{0}'.format(lineitem.vendor) if lineitem.vendor else '',
'category': '{0}'.format(category) if lineitem.category else '',
'purpose': '{0}'.format(lineitem.purpose) if lineitem.purpose else '',
'report_number': '{0}'.format(lineitem.claim_number),
'spent_on': '{0}'.format(lineitem.spent_at.date()) if lineitem.spent_at else '',
'expense_link': expense_link
}
purpose = ''
for id, field in enumerate(memo_structure):
if field in details:
purpose += details[field]
if id + 1 != len(memo_structure):
purpose += ' - '
return purpose


def get_total_amount(accounting_export: AccountingExport):
"""
Calculate the total amount of expenses associated with a given AccountingExport

Parameters:
- accounting_export (AccountingExport): The AccountingExport instance for which to calculate the total amount.

Returns:
- float: The total amount of expenses associated with the provided AccountingExport.
"""

# Using the related name 'expenses' to access the expenses associated with the given AccountingExport
total_amount = accounting_export.expenses.aggregate(Sum('amount'))['amount__sum']

# If there are no expenses for the given AccountingExport, 'total_amount' will be None
# Handle this case by returning 0 or handling it as appropriate for your application
return total_amount or 0.0

def get_invoice_date(accounting_export: AccountingExport) -> str:
"""
Get the invoice date from the provided AccountingExport.

Parameters:
- accounting_export (AccountingExport): The AccountingExport instance containing the description field.

Returns:
- str: The invoice date as a string in the format '%Y-%m-%dT%H:%M:%S'.
"""
# Check for specific keys in the 'description' field and return the corresponding value
if 'spent_at' in accounting_export.description and accounting_export.description['spent_at']:
return accounting_export.description['spent_at']
elif 'approved_at' in accounting_export.description and accounting_export.description['approved_at']:
return accounting_export.description['approved_at']
elif 'verified_at' in accounting_export.description and accounting_export.description['verified_at']:
return accounting_export.description['verified_at']
elif 'last_spent_at' in accounting_export.description and accounting_export.description['last_spent_at']:
return accounting_export.description['last_spent_at']
elif 'posted_at' in accounting_export.description and accounting_export.description['posted_at']:
return accounting_export.description['posted_at']

# If none of the expected keys are present or if the values are empty, return the current date and time
return datetime.now().strftime("%Y-%m-%d")
Comment on lines +76 to +99
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The extended search output confirms that the get_invoice_date method in base_model.py returns the current date in the format '%Y-%m-%d' when none of the expected keys are present or their values are empty. This format does not match the format specified in the method's docstring ('%Y-%m-%dT%H:%M:%S'). Therefore, there is an inconsistency between the docstring and the actual behavior of the method.

The review comment is correct in pointing out that the method should ensure that the date format returned is consistent with the expected format mentioned in the docstring. The current implementation does not do this when it falls back to the current date.

Analysis chain

The get_invoice_date method extracts the invoice date from various fields within the description of an AccountingExport object. It falls back to the current date if none of the expected keys are present. This method should ensure that the date format returned is consistent and matches the expected format '%Y-%m-%dT%H:%M:%S' as mentioned in the docstring.

Scripts Executed

The following scripts were executed for the analysis:


Script:

#!/bin/bash
# Verify that the date format returned by get_invoice_date is consistent with '%Y-%m-%dT%H:%M:%S'
# This script should be run in a test environment where the method's behavior can be safely evaluated.

Length of output: 0



Script:

#!/bin/bash
# Search for the implementation of the get_invoice_date method in base_model.py
# and display the relevant code block that handles the return of the current date.
rg "def get_invoice_date" -A 20 apps/business_central/exports/base_model.py

Length of output: 1421



Script:

#!/bin/bash
# Extend the search to include the entire get_invoice_date method to see the fallback date format.
rg "def get_invoice_date" -A 30 apps/business_central/exports/base_model.py

Length of output: 1926


def get_vendor_id(accounting_export: AccountingExport) -> str:
return ""

def get_employee_id(accounting_export: AccountingExport) -> str:
return ""
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The methods get_vendor_id and get_employee_id are placeholders returning empty strings. If these methods are meant to be implemented, add a TODO comment or implement them. If they are not needed, consider removing them to avoid confusion.

Loading
Loading