diff --git a/.gitignore b/.gitignore index 323c3d09ba4b8ed625f100c5f4d2b801bca1ecb4..7f66389b2096d442b6038bd1d038c4a75c6dbb46 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,4 @@ webpack-stats.json # Compiled language files *.mo +.env diff --git a/hypha/apply/projects/migrations/0047_add_external_project_id_for_project_and_deliverables.py b/hypha/apply/projects/migrations/0048_add_fields_for_finance_integrations.py similarity index 50% rename from hypha/apply/projects/migrations/0047_add_external_project_id_for_project_and_deliverables.py rename to hypha/apply/projects/migrations/0048_add_fields_for_finance_integrations.py index d4f60a79f954ce1bb031f957a20596bcfc6b8659..ede1237a0beac5fd008d985e85e1c6782afd80b1 100644 --- a/hypha/apply/projects/migrations/0047_add_external_project_id_for_project_and_deliverables.py +++ b/hypha/apply/projects/migrations/0048_add_fields_for_finance_integrations.py @@ -1,4 +1,4 @@ -# Generated by Django 2.2.26 on 2022-02-15 04:37 +# Generated by Django 3.2.12 on 2022-03-09 07:32 from django.db import migrations, models @@ -6,7 +6,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('application_projects', '0046_add_required_checks_field'), + ('application_projects', '0047_alter_project_form_data'), ] operations = [ @@ -15,6 +15,16 @@ class Migration(migrations.Migration): name='external_id', field=models.CharField(blank=True, help_text='ID of this deliverable at integrated payment service.', max_length=30), ), + migrations.AddField( + model_name='deliverable', + name='extra_information', + field=models.JSONField(default=dict, help_text='More details of the deliverable at integrated payment service.'), + ), + migrations.AddField( + model_name='project', + name='external_project_information', + field=models.JSONField(default=dict, help_text='More details of the project integrated at payment service.'), + ), migrations.AddField( model_name='project', name='external_projectid', diff --git a/hypha/apply/projects/models/payment.py b/hypha/apply/projects/models/payment.py index d2fc42609f6cc0456f134a03982a4db5f3c48027..e5e79d5bcbb17101095fc81c2368e77876181655 100644 --- a/hypha/apply/projects/models/payment.py +++ b/hypha/apply/projects/models/payment.py @@ -169,6 +169,11 @@ class Invoice(models.Model): def status_display(self): return self.get_status_display() + @property + def vendor_document_number(self): + prefix = 'HP' + return prefix + f"{self.id:06}" + def can_user_delete(self, user): if user.is_applicant or user.is_apply_staff or user.is_finance_level_1 or user.is_finance_level_2 or user.is_contracting: if self.status in (SUBMITTED): diff --git a/hypha/apply/projects/models/project.py b/hypha/apply/projects/models/project.py index 737e1bb967814e76ec1d6ed985dff43db2082b12..6018b9f890ac9beb01374488070dc15a2f924e62 100644 --- a/hypha/apply/projects/models/project.py +++ b/hypha/apply/projects/models/project.py @@ -162,6 +162,10 @@ class Project(BaseStreamForm, AccessFormData, models.Model): blank=True, help_text='ID of this project at integrated payment service.' ) + external_project_information = models.JSONField( + default=dict, + help_text='More details of the project integrated at payment service.' + ) sent_to_compliance_at = models.DateTimeField(null=True) objects = ProjectQuerySet.as_manager() @@ -342,6 +346,13 @@ class Project(BaseStreamForm, AccessFormData, models.Model): def has_deliverables(self): return self.deliverables.exists() + @property + def program_project_id(self): + reference_number = self.external_project_information.get('PONUMBER', None) + if reference_number: + return reference_number.split('-')[0] + return '' + # def send_to_compliance(self, request): # """Notify Compliance about this Project.""" @@ -457,6 +468,10 @@ class Deliverable(models.Model): decimal_places=2, validators=[MinValueValidator(decimal.Decimal('0.01'))], ) + extra_information = models.JSONField( + default=dict, + help_text='More details of the deliverable at integrated payment service.' + ) project = models.ForeignKey( Project, null=True, blank=True, diff --git a/hypha/apply/projects/services/sageintacct/sageintacctsdk.py b/hypha/apply/projects/services/sageintacct/sageintacctsdk.py index 977912e3ea8f2af6494a31da61f31d6f03deedb0..6eed63df5a5146ad9c6011a1235a7addca864f12 100644 --- a/hypha/apply/projects/services/sageintacct/sageintacctsdk.py +++ b/hypha/apply/projects/services/sageintacct/sageintacctsdk.py @@ -1,4 +1,4 @@ -from .wrapper import ApiBase, Purchasing +from .wrapper import ApiBase, Invoice, Project, Purchasing class SageIntacctSDK: @@ -30,6 +30,8 @@ class SageIntacctSDK: self.api_base = ApiBase() self.purchasing = Purchasing() + self.project = Project() + self.invoice = Invoice() self.update_sender_id() self.update_sender_password() self.update_session_id() @@ -40,6 +42,8 @@ class SageIntacctSDK: """ self.api_base.set_sender_id(self.__sender_id) self.purchasing.set_sender_id(self.__sender_id) + self.project.set_sender_id(self.__sender_id) + self.invoice.set_sender_id(self.__sender_id) def update_sender_password(self): """ @@ -47,6 +51,8 @@ class SageIntacctSDK: """ self.api_base.set_sender_password(self.__sender_password) self.purchasing.set_sender_password(self.__sender_password) + self.project.set_sender_password(self.__sender_password) + self.invoice.set_sender_password(self.__sender_password) def update_session_id(self): """ @@ -60,3 +66,5 @@ class SageIntacctSDK: ) self.api_base.set_session_id(self.__session_id) self.purchasing.set_session_id(self.__session_id) + self.project.set_session_id(self.__session_id) + self.invoice.set_session_id(self.__session_id) diff --git a/hypha/apply/projects/services/sageintacct/utils.py b/hypha/apply/projects/services/sageintacct/utils.py index 41991335437dcf2edf83b9f83810a9e33f8eff2f..fd01f7618e6bacf9993790746b9d79137933652d 100644 --- a/hypha/apply/projects/services/sageintacct/utils.py +++ b/hypha/apply/projects/services/sageintacct/utils.py @@ -1,4 +1,5 @@ import logging +from datetime import timedelta from django.conf import settings @@ -32,3 +33,115 @@ def fetch_deliverables(program_project_id=''): deliverables = connection.purchasing.get_by_query(filter_payload=formatted_filter) return deliverables + + +def get_deliverables_json(invoice): + deliverables = invoice.deliverables.all() + deliverables_list = [] + for deliverable in deliverables: + project_deliverable = deliverable.deliverable + extra_info = project_deliverable.extra_information + deliverables_list.append( + { + 'itemid': project_deliverable.external_id, + 'quantity': deliverable.quantity, + 'unit': extra_info['UNIT'], + 'price': project_deliverable.unit_price, + 'locationid': extra_info['LOCATIONID'], + 'departmentid': extra_info['DEPARTMENTID'], + 'projectid': extra_info['PROJECTID'], + 'classid': extra_info['CLASSID'], + } + ) + return deliverables_list + + +def create_intacct_invoice(invoice): + project = invoice.project + external_project_information = project.external_project_information + external_projectid = project.external_projectid + transactiontype = 'Contract Invoice Release' + date_created = invoice.requested_at + createdfrom = external_project_information['DOCPARID'] + '-' + external_projectid + vendorid = external_project_information['CUSTVENDID'] + referenceno = external_project_information['PONUMBER'] + project.created_at + timedelta(days=20) + datedue = date_created + timedelta(days=20) + contract_start_date = project.proposed_start + contract_end_date = project.proposed_end + deliverables = get_deliverables_json(invoice) + vendordocno = invoice.vendor_document_number + data = { + 'transactiontype': transactiontype, + 'datecreated': { + 'year': date_created.year, + 'month': date_created.month, + 'day': date_created.day, + }, + 'createdfrom': createdfrom, + 'vendorid': vendorid, + 'referenceno': referenceno, + 'vendordocno': vendordocno, + 'datedue': { + 'year': datedue.year, + 'month': datedue.month, + 'day': datedue.day, + }, + 'returnto': { + 'contactname': '' + }, + 'payto': { + 'contactname': '' + }, + 'customfields': { + 'customfield': [ + { + 'customfieldname': 'CONTRACT_START_DATE', + 'customfieldvalue': f'{contract_start_date.month}/{contract_start_date.day}/{contract_start_date.year}' + }, + { + 'customfieldname': 'CONTRACT_END_DATE', + 'customfieldvalue': f'{contract_end_date.month}/{contract_end_date.day}/{contract_end_date.year}' + } + ] + }, + 'potransitems': { + 'potransitem': deliverables + } + } + try: + connection = SageIntacctSDK( + sender_id=settings.INTACCT_SENDER_ID, + sender_password=settings.INTACCT_SENDER_PASSWORD, + user_id=settings.INTACCT_USER_ID, + company_id=settings.INTACCT_COMPANY_ID, + user_password=settings.INTACCT_USER_PASSWORD + ) + except Exception as e: + logging.error(e) + return + invoice = connection.invoice.post(data) + return invoice + + +def fetch_project_details(external_projectid): + formatted_filter = { + 'equalto': {'field': 'DOCNO', 'value': external_projectid} + } + + try: + connection = SageIntacctSDK( + sender_id=settings.INTACCT_SENDER_ID, + sender_password=settings.INTACCT_SENDER_PASSWORD, + user_id=settings.INTACCT_USER_ID, + company_id=settings.INTACCT_COMPANY_ID, + user_password=settings.INTACCT_USER_PASSWORD + ) + except Exception as e: + logging.error(e) + return {} + + data = connection.project.get_by_query(filter_payload=formatted_filter) + if data: + return data[0] + return {} diff --git a/hypha/apply/projects/services/sageintacct/wrapper/__init__.py b/hypha/apply/projects/services/sageintacct/wrapper/__init__.py index 2e601e93577c86d43e095dd39b7f7d75a84eaf87..10e4a384b3b5eba78419a0007a2e4c15bd9878a9 100644 --- a/hypha/apply/projects/services/sageintacct/wrapper/__init__.py +++ b/hypha/apply/projects/services/sageintacct/wrapper/__init__.py @@ -1,7 +1,11 @@ from .api_base import ApiBase +from .invoice import Invoice +from .project import Project from .purchasing import Purchasing __all__ = [ 'ApiBase', 'Purchasing', + 'Invoice', + 'Project', ] diff --git a/hypha/apply/projects/services/sageintacct/wrapper/constants.py b/hypha/apply/projects/services/sageintacct/wrapper/constants.py index ceae10b28eea0f3b19c7199c9c39ee8d3dade065..e8912386dadf25d66c01d6a0b56eee79b538f5ca 100644 --- a/hypha/apply/projects/services/sageintacct/wrapper/constants.py +++ b/hypha/apply/projects/services/sageintacct/wrapper/constants.py @@ -6,5 +6,16 @@ dimensions_fields_mapping = { 'QTY_REMAINING', 'UNIT', 'PRICE', + 'PROJECTID', + 'LOCATIONID', + 'CLASSID', + 'BILLABLE', + 'DEPARTMENTID', ], + 'PODOCUMENT': [ + 'DOCNO', + 'DOCPARID', + 'PONUMBER', + 'CUSTVENDID' + ] } diff --git a/hypha/apply/projects/services/sageintacct/wrapper/invoice.py b/hypha/apply/projects/services/sageintacct/wrapper/invoice.py new file mode 100644 index 0000000000000000000000000000000000000000..81a9c8fc3edd5b026abd1af5c873143603fcbe5a --- /dev/null +++ b/hypha/apply/projects/services/sageintacct/wrapper/invoice.py @@ -0,0 +1,11 @@ +from .api_base import ApiBase + + +class Invoice(ApiBase): + """Class to create Contract Invoice Release at Sage IntAcct.""" + + def post(self, data: dict): + data = { + 'create_potransaction': data + } + return self.format_and_send_request(data) diff --git a/hypha/apply/projects/services/sageintacct/wrapper/project.py b/hypha/apply/projects/services/sageintacct/wrapper/project.py new file mode 100644 index 0000000000000000000000000000000000000000..96387eecd0a66a65932b1db14104342d91b1f1a4 --- /dev/null +++ b/hypha/apply/projects/services/sageintacct/wrapper/project.py @@ -0,0 +1,11 @@ + +""" +Sage Intacct contract +""" +from .api_base import ApiBase + + +class Project(ApiBase): + """Class for contract APIs.""" + def __init__(self): + ApiBase.__init__(self, dimension='PODOCUMENT') diff --git a/hypha/apply/projects/utils.py b/hypha/apply/projects/utils.py index e8e7f53f2a9fac8f39e8fdeb97ccf838eb03ccb4..621802217969f4540f466d43f7db56a0a570ad38 100644 --- a/hypha/apply/projects/utils.py +++ b/hypha/apply/projects/utils.py @@ -3,12 +3,14 @@ from django.conf import settings from .models import Deliverable, Project -def fetch_and_save_deliverables(project_id, program_project_id=''): +def fetch_and_save_deliverables(project_id): """ Get deliverables from various third party integrations. """ if settings.INTACCT_ENABLED: from hypha.apply.projects.services.sageintacct.utils import fetch_deliverables + project = Project.objects.get(id=project_id) + program_project_id = project.program_project_id deliverables = fetch_deliverables(program_project_id) save_deliverables(project_id, deliverables) @@ -23,12 +25,21 @@ def save_deliverables(project_id, deliverables=[]): item_name = deliverable['ITEMNAME'] qty_remaining = int(float(deliverable['QTY_REMAINING'])) price = deliverable['PRICE'] + extra_information = { + 'UNIT': deliverable['UNIT'], + 'DEPARTMENTID': deliverable['DEPARTMENTID'], + 'PROJECTID': deliverable['PROJECTID'], + 'LOCATIONID': deliverable['LOCATIONID'], + 'CLASSID': deliverable['CLASSID'], + 'BILLABLE': deliverable['BILLABLE'] + } new_deliverable_list.append( Deliverable( external_id=item_id, name=item_name, available_to_invoice=qty_remaining, unit_price=price, + extra_information=extra_information, project=project ) ) @@ -41,3 +52,26 @@ def remove_deliverables_from_project(project_id): for deliverable in deliverables: deliverable.project = None deliverable.save() + + +def fetch_and_save_project_details(project_id, external_projectid): + if settings.INTACCT_ENABLED: + from hypha.apply.projects.services.sageintacct.utils import ( + fetch_project_details, + ) + data = fetch_project_details(external_projectid) + save_project_details(project_id, data) + + +def save_project_details(project_id, data): + project = Project.objects.get(id=project_id) + project.external_project_information = data + project.save() + + +def create_invoice(invoice): + if settings.INTACCT_ENABLED: + from hypha.apply.projects.services.sageintacct.utils import ( + create_intacct_invoice, + ) + create_intacct_invoice(invoice) diff --git a/hypha/apply/projects/views/payment.py b/hypha/apply/projects/views/payment.py index babb20349c705f7f988190658491cd6e2d622aa4..9798155e877e1a04bdee3d17856213fd6e833923 100644 --- a/hypha/apply/projects/views/payment.py +++ b/hypha/apply/projects/views/payment.py @@ -18,10 +18,14 @@ from hypha.apply.utils.views import DelegateableView, DelegatedViewMixin, ViewDi from ..filters import InvoiceListFilter from ..forms import ChangeInvoiceStatusForm, CreateInvoiceForm, EditInvoiceForm -from ..models.payment import INVOICE_TRANISTION_TO_RESUBMITTED, Invoice +from ..models.payment import ( + APPROVED_BY_FINANCE_2, + INVOICE_TRANISTION_TO_RESUBMITTED, + Invoice, +) from ..models.project import Project from ..tables import InvoiceListTable -from ..utils import fetch_and_save_deliverables +from ..utils import create_invoice, fetch_and_save_deliverables @method_decorator(login_required, name='dispatch') @@ -76,6 +80,10 @@ class ChangeInvoiceStatusView(DelegatedViewMixin, InvoiceAccessMixin, UpdateView related=self.object, ) + if form.cleaned_data['status'] == APPROVED_BY_FINANCE_2: + # Create Invoice at integrated payment service + create_invoice(self.object) + return response @@ -119,7 +127,7 @@ class InvoiceAdminView(InvoiceAccessMixin, DelegateableView, DetailView): external_projectid = project.external_projectid if external_projectid and not invoice.deliverables.exists(): # Once the deliverables has been attached on an invoice do not make API call - deliverables = fetch_and_save_deliverables(project.id, external_projectid) + deliverables = fetch_and_save_deliverables(project.id) deliverables = project.deliverables.all() return super().get_context_data( **kwargs, diff --git a/hypha/apply/projects/views/project.py b/hypha/apply/projects/views/project.py index 0bc20a25b5412743d83651d0da37320183d4bf33..1867abac30ec97e548580a5d97b1bc63cc265bdd 100644 --- a/hypha/apply/projects/views/project.py +++ b/hypha/apply/projects/views/project.py @@ -68,6 +68,7 @@ from ..models.project import ( ) from ..models.report import Report from ..tables import InvoiceListTable, ProjectsListTable, ReportListTable +from ..utils import fetch_and_save_project_details from .report import ReportFrequencyUpdate, ReportingMixin @@ -601,7 +602,11 @@ class ProjectApprovalEditView(UpdateView): return super().dispatch(request, *args, **kwargs) def form_valid(self, form): - return super().form_valid(form) + response = super().form_valid(form) + external_projectid = form.cleaned_data['external_projectid'] + if external_projectid: + fetch_and_save_project_details(self.object.id, external_projectid) + return response @method_decorator(staff_or_finance_required, name='dispatch') diff --git a/hypha/settings/base.py b/hypha/settings/base.py index bcb9b798f8c5d534ae9f18828bf84115e8b3f37b..8bf20153b1130639136252599562dd145f03811f 100644 --- a/hypha/settings/base.py +++ b/hypha/settings/base.py @@ -652,15 +652,13 @@ if env.str('AWS_STORAGE_BUCKET_NAME', None): # Matomo tracking -MATOMO_URL = env.get('MATOMO_URL', False) -MATOMO_SITEID = env.get('MATOMO_SITEID', False) - - -INTACCT_ENABLED = False -if env.get('INTACCT_ENABLED', 'false').lower().strip() == 'true': - INTACCT_ENABLED = True - INTACCT_SENDER_ID = env['INTACCT_SENDER_ID'] - INTACCT_SENDER_PASSWORD = env['INTACCT_SENDER_PASSWORD'] - INTACCT_USER_ID = env['INTACCT_USER_ID'] - INTACCT_COMPANY_ID = env['INTACCT_COMPANY_ID'] - INTACCT_USER_PASSWORD = env['INTACCT_USER_PASSWORD'] +MATOMO_URL = env.bool('MATOMO_URL', False) +MATOMO_SITEID = env.bool('MATOMO_SITEID', False) + + +INTACCT_ENABLED = env.bool('INTACCT_ENABLED', False) +INTACCT_SENDER_ID = env.str('INTACCT_SENDER_ID', '') +INTACCT_SENDER_PASSWORD = env.str('INTACCT_SENDER_PASSWORD', '') +INTACCT_USER_ID = env.str('INTACCT_USER_ID', '') +INTACCT_COMPANY_ID = env.str('INTACCT_COMPANY_ID', '') +INTACCT_USER_PASSWORD = env.str('INTACCT_USER_PASSWORD', '')