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
76 changes: 76 additions & 0 deletions apps/business_central/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@

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 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)
Comment on lines +16 to +72
Copy link

Choose a reason for hiding this comment

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

The function handle_business_central_exceptions is a decorator designed to handle various exceptions that can occur during the export process. The following points need attention:

  • Line 16-27: The function handle_business_central_error logs the error and updates the AccountingExport object. It's important to ensure that the exception.response contains the expected format to be stored in business_central_errors.
  • Line 30-76: The decorator handle_business_central_exceptions is well-structured to catch different exceptions. However, the error messages and handling seem to be specific to Business Central and Sage300, which might be a copy-paste error (line 46 mentions Sage300 instead of Business Central).
  • Line 72: The call to update_accounting_export_summary should be inside a finally block to ensure it's called even if an exception is not raised.

Correct the potential copy-paste error in the exception message on line 46 to reflect the correct system (Business Central). Also, consider moving the update_accounting_export_summary call to a finally block to ensure it's always executed.


return new_fn

return decorator
Empty file.
76 changes: 76 additions & 0 deletions apps/business_central/exports/accounting_export.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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

validate_accounting_export(accounting_export, export_settings)
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, export_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)
Loading
Loading