From 007fbfbfb7957ba46007162d0a5af33b1316ba40 Mon Sep 17 00:00:00 2001 From: Aditya Hase Date: Wed, 17 Oct 2018 17:50:00 +0530 Subject: [PATCH] [Feature] ERPNext Quickbooks Migrator (#15718) * Create QuickBooks Connector Single DocType * Create interface for user authorization and obtaining authorization code * Obtain Access token using authorization code * Fetch a random customer * Schedule fetching as a background job * Save fetched customer * Fetch all customers, take care of pagination * Save fetched customers * Create a custom field for storing Quickbooks ID * Don't save already saved customers * Commit every successful insert * No need of allow_guest=True * Fetch Items as well * Store Customer Addresses as well. * Remove redundant custom field creation code * Stupid refactoring * Some more refactoring * Fetch and Save Suppliers as well * Save accounts. Really a hack as of now. * Fetch and save invoices, Take care of child items as well * Fetch Taxes as well * Set currency on Sales Invoice * Correctly Link Items, Handle markups * Don't enqueue, Need to wait forever for testing sometimes * Set margin rate correctly * Correct fieldname is tax_amount not amount * Fetch with maximum possible batch size * Don't print unnecessary info * Fetch Journal Entries * Fetch Purchase Invoices (Bill) * Don't perform reauthentication if you already have an access_token * Don't need those print statements anymore * Refresh access_token if request fails * Fetch Payment Entry(Payment) * Map QB items to ERPNext rather than other way around * Don't need a huge comment block as of now * Fetch Payment Entries against Purchase Invoices (BillPayment) * Rename Quickbooks Connector to Migrator * Make oauth settings configurable * Make company configurable * Make default accounts configurable * Fetch accounts and other masters separately * Show realtime progress * Check if entries exist before attempting insert * Suppress other annoying messages * Tracebacks are good for health * Don't want rounded total * Don't call fetch after authorization (Need to somehow separate these) * Make credit_to account payable * When generating payment entry mention bank_account as well * Cleanup * Add custom buttons and reflect current state in form ui * Show necessary fields on form depending on the current state * Cleanup * Minor fixes * Set income and expense account on items. * Set currency for Account. * Stupid VSCode * Remove redundant code * Check for existing Payment and BillPayment correctly * Cache API response for faster development * Don't maintain stock for now, Seems to solve the issue with 'Stock Received but not billed' * Cleanup * Add methods to remove inserted data completely (Development) * Don't commit in every iteration * Set account head based on TaxRate in Sales Invoice * Fetch and cache TaxCode * Add methods to fetch TaxCode and TaxRate from cache * Set item wise tax breakup, Don't use Actual Tax * Use both TaxRateLists * Set Itemwise tax on Purchase Invoice as well * Set bank_amount, while creating payment entries * Remove print statements * Add Shipping in taxes child table of Sales Invoice * Set posting date on Payment Entries against Invoices * Fetch and save expenses as Journal Entries * Fetch and save Deposits as Journal Entries * Fetch and Save Credit and Debit Notes * Fetch and save SalesReceipt * Record Shipping as Item instead of Tax * Minor Fixes * Fetch Advance Payments * Set account_type during Account creation * Choose receivable account in Invoice based on currency * Remove receivable_account field * Account type is already set during account creation * Set receivable account based on currency in Sales Receipt and Credit Memo * Set receivable account on Customer * Save Company Preferences * Set shipping_account based on company preferences * Set cost center in Sales Invoice Items and Taxes * Make Account name unique in case of duplicate account name * Fetch and Save TaxPayment as Journal Entry * Record UndepositedFunds Account in cache if found * Credit UndepositedFunds account when mentioned in Deposit * Debit Cashback account as well if mentioned in Deposit * Set currency conversion rate * Set is_group field correctly for accounts * Preserve account hierarchy * Use quickbooks_id and company together for filtering * Make new custom field 'company' on Supplier Customer and Item * Use comapny field for filtering Customer, Item, Supplier * Shipping Account is only available if shipping is enabled for comapny * Invoice might not have TxnTaxDetail.TaxLine * Lookup TaxRate account correctly * TaxCode might not have SalesTaxRateList PurchaseTaxRateList or Both * TaxCode might not have SalesTaxRateList and PurchaseTaxRateList * Remove unnecessary UI elements * Allow multiple companies to have customers with same name * Refactor account_type * Create a leaf an account for every group account * Item and Tax list creation needs more information * Supports discount in Invoice * Save Invoice as Journal Entry when itemwise details are unavailable * Credit=True represents a refund rather than charge * Mode of Payment not synced, setting as Cash for now * Tax code may also be set for an entire invoice * Fetch General Ledger Report correctly * Refactor fetching entries from General Ledger Report * Tax Payment is the same as Sales Tax Payment and Purchase Tax Payment * append_number_if_name_exists is stupid, Reimplementing * Don't guess credit/debit for Tax Payment and Advanced Payment * Bill Payment shouldn't assume full payment * Save Payment as Journal Entry * Make Client ID and Client Secret Default * Remove unused python imports * removed are_accounts_synced() * Don't store translated values in database * Endpoints as a field * Major refactor - functions to methods * Major Refactor Functions to Methods * Major Refactor Fix Stuff * Minor Changes * Save Refund As well * Minor Refactor (SI varations to SI, PI variations to PI) * Record Inventory Qty Adjust Entries * QBM Final Touches * Make changes suggested in review --- .../doctype/quickbooks_migrator/__init__.py | 0 .../quickbooks_migrator.js | 71 + .../quickbooks_migrator.json | 843 +++++++++++ .../quickbooks_migrator.py | 1274 +++++++++++++++++ .../test_quickbooks_migrator.js | 23 + .../test_quickbooks_migrator.py | 10 + 6 files changed, 2221 insertions(+) create mode 100644 erpnext/erpnext_integrations/doctype/quickbooks_migrator/__init__.py create mode 100644 erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.js create mode 100644 erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.json create mode 100644 erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.py create mode 100644 erpnext/erpnext_integrations/doctype/quickbooks_migrator/test_quickbooks_migrator.js create mode 100644 erpnext/erpnext_integrations/doctype/quickbooks_migrator/test_quickbooks_migrator.py diff --git a/erpnext/erpnext_integrations/doctype/quickbooks_migrator/__init__.py b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.js b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.js new file mode 100644 index 0000000000..bfffcc5153 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.js @@ -0,0 +1,71 @@ +// Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors +// For license information, please see license.txt + +frappe.ui.form.on('QuickBooks Migrator', { + connect: function(frm) { + // OAuth requires user intervention to provide application access permissionsto requested scope + // Here we open a new window and redirect user to the authorization url. + // After user grants us permission to access. We will set authorization details on this doc which will force refresh. + window.open(frm.doc.authorization_url) + }, + fetch_data: function(frm) { + frm.call("migrate") + }, + onload: function(frm) { + frm.trigger("set_indicator") + var domain = frappe.urllib.get_base_url() + var redirect_url = `${domain}/api/method/erpnext.erpnext_integrations.doctype.quickbooks_migrator.quickbooks_migrator.callback` + if (frm.doc.redirect_url != redirect_url) { + frm.set_value("redirect_url", redirect_url) + } + // Instead of changing percentage width and message of single progress bar + // Show a different porgress bar for every action after some time remove the finished progress bar + // Former approach causes the progress bar to dance back and forth. + frm.trigger("set_indicator") + frappe.realtime.on("quickbooks_progress_update", function (data) { + frm.dashboard.show_progress(data.message, (data.count / data.total) * 100, data.message) + if (data.count == data.total) { + window.setTimeout( function(message) {frm.dashboard.hide_progress(message)}, 1500, data.messsage) + } + }) + }, + refresh: function(frm) { + frm.trigger("set_indicator") + if (!frm.doc.access_token) { + // Unset access_token signifies that we don't have enough information to connect to quickbooks api and fetch data + if (frm.doc.authorization_url) { + frm.add_custom_button(__("Connect to Quickbooks"), function () { + frm.trigger("connect") + }); + } + } + if (frm.doc.access_token) { + // If we have access_token that means we also have refresh_token we don't need user intervention anymore + // All we need now is a Company from erpnext + frm.remove_custom_button(__("Connect to Quickbooks")) + + frm.toggle_display("company_settings", 1) + frm.set_df_property("company", "reqd", 1) + if (frm.doc.company) { + frm.add_custom_button(__("Fetch Data"), function () { + frm.trigger("fetch_data") + }); + } + } + }, + set_indicator: function(frm) { + var indicator_map = { + "Connecting to QuickBooks": [__("Connecting to QuickBooks"), "orange"], + "Connected to QuickBooks": [__("Connected to QuickBooks"), "green"], + "In Progress": [__("In Progress"), "orange"], + "Complete": [__("Complete"), "green"], + "Failed": [__("Failed"), "red"], + } + if (frm.doc.status) { + var indicator = indicator_map[frm.doc.status] + var label = indicator[0] + var color = indicator[1] + frm.page.set_indicator(label, color) + } + }, +}); diff --git a/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.json b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.json new file mode 100644 index 0000000000..b156006e28 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.json @@ -0,0 +1,843 @@ +{ + "allow_copy": 0, + "allow_events_in_timeline": 0, + "allow_guest_to_view": 0, + "allow_import": 0, + "allow_rename": 0, + "beta": 1, + "creation": "2018-07-10 14:48:16.757030", + "custom": 0, + "docstatus": 0, + "doctype": "DocType", + "document_type": "", + "editable_grid": 1, + "engine": "InnoDB", + "fields": [ + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "status", + "fieldtype": "Select", + "hidden": 1, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Status", + "length": 0, + "no_copy": 0, + "options": "Connecting to QuickBooks\nConnected to QuickBooks\nIn Progress\nComplete\nFailed", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 1, + "collapsible_depends_on": "eval:doc.client_id && doc.client_secret && doc.redirect_url", + "columns": 0, + "fieldname": "application_settings", + "fieldtype": "Section Break", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Application Settings", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": "", + "fieldname": "client_id", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_standard_filter": 0, + "label": "Client ID", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 1, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": "", + "fieldname": "redirect_url", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_standard_filter": 0, + "label": "Redirect URL", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 1, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer", + "fieldname": "token_endpoint", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Token Endpoint", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 1, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 1, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "application_column_break", + "fieldtype": "Column Break", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": "", + "fieldname": "client_secret", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_standard_filter": 0, + "label": "Client Secret", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 1, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": "com.intuit.quickbooks.accounting", + "fieldname": "scope", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 1, + "in_standard_filter": 0, + "label": "Scope", + "length": 0, + "no_copy": 0, + "options": "", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 1, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 1, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": "https://quickbooks.api.intuit.com/v3", + "fieldname": "api_endpoint", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "API Endpoint", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 1, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 1, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 1, + "columns": 0, + "fieldname": "authorization_settings", + "fieldtype": "Section Break", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Authorization Settings", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "default": "https://appcenter.intuit.com/connect/oauth2", + "fieldname": "authorization_endpoint", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Authorization Endpoint", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 1, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 1, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "refresh_token", + "fieldtype": "Small Text", + "hidden": 1, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Refresh Token", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "code", + "fieldtype": "Data", + "hidden": 1, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Code", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "authorization_column_break", + "fieldtype": "Column Break", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "authorization_url", + "fieldtype": "Data", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Authorization URL", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 1, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 1, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "access_token", + "fieldtype": "Small Text", + "hidden": 1, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Access Token", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "quickbooks_company_id", + "fieldtype": "Data", + "hidden": 1, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Quickbooks Company ID", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "company_settings", + "fieldtype": "Section Break", + "hidden": 1, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Company Settings", + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "company", + "fieldtype": "Link", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Company", + "length": 0, + "no_copy": 0, + "options": "Company", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "default_shipping_account", + "fieldtype": "Link", + "hidden": 1, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Default Shipping Account", + "length": 0, + "no_copy": 0, + "options": "Account", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "default_warehouse", + "fieldtype": "Link", + "hidden": 1, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Default Warehouse", + "length": 0, + "no_copy": 0, + "options": "Warehouse", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "company_column_break", + "fieldtype": "Column Break", + "hidden": 0, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "length": 0, + "no_copy": 0, + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "default_cost_center", + "fieldtype": "Link", + "hidden": 1, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Default Cost Center", + "length": 0, + "no_copy": 0, + "options": "Cost Center", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + }, + { + "allow_bulk_edit": 0, + "allow_in_quick_entry": 0, + "allow_on_submit": 0, + "bold": 0, + "collapsible": 0, + "columns": 0, + "fieldname": "undeposited_funds_account", + "fieldtype": "Link", + "hidden": 1, + "ignore_user_permissions": 0, + "ignore_xss_filter": 0, + "in_filter": 0, + "in_global_search": 0, + "in_list_view": 0, + "in_standard_filter": 0, + "label": "Undeposited Funds Account", + "length": 0, + "no_copy": 0, + "options": "Account", + "permlevel": 0, + "precision": "", + "print_hide": 0, + "print_hide_if_no_value": 0, + "read_only": 0, + "remember_last_selected_value": 0, + "report_hide": 0, + "reqd": 0, + "search_index": 0, + "set_only_once": 0, + "translatable": 0, + "unique": 0 + } + ], + "has_web_view": 0, + "hide_heading": 0, + "hide_toolbar": 0, + "idx": 0, + "image_view": 0, + "in_create": 0, + "is_submittable": 0, + "issingle": 1, + "istable": 0, + "max_attachments": 0, + "modified": "2018-10-17 03:12:53.506229", + "modified_by": "Administrator", + "module": "ERPNext Integrations", + "name": "QuickBooks Migrator", + "name_case": "", + "owner": "Administrator", + "permissions": [ + { + "amend": 0, + "cancel": 0, + "create": 1, + "delete": 1, + "email": 1, + "export": 0, + "if_owner": 0, + "import": 0, + "permlevel": 0, + "print": 1, + "read": 1, + "report": 0, + "role": "System Manager", + "set_user_permissions": 0, + "share": 1, + "submit": 0, + "write": 1 + } + ], + "quick_entry": 1, + "read_only": 0, + "read_only_onload": 0, + "show_name_in_global_search": 1, + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 0, + "track_seen": 0, + "track_views": 0 +} \ No newline at end of file diff --git a/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.py b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.py new file mode 100644 index 0000000000..29a1a2b0bb --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/quickbooks_migrator.py @@ -0,0 +1,1274 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors +# For license information, please see license.txt + +from __future__ import unicode_literals +import frappe +from frappe import _ +from frappe.model.document import Document +from requests_oauthlib import OAuth2Session +import json, requests +from erpnext import encode_company_abbr + +# QuickBooks requires a redirect URL, User will be redirect to this URL +# This will be a GET request +# Request parameters will have two parameters `code` and `realmId` +# `code` is required to acquire refresh_token and access_token +# `realmId` is the QuickBooks Company ID. It is Needed to actually fetch data. +@frappe.whitelist() +def callback(*args, **kwargs): + migrator = frappe.get_doc("QuickBooks Migrator") + migrator.set_indicator("Connecting to QuickBooks") + migrator.code = kwargs.get("code") + migrator.quickbooks_company_id = kwargs.get("realmId") + migrator.save() + migrator.get_tokens() + frappe.db.commit() + migrator.set_indicator("Connected to QuickBooks") + # We need this page to automatically close afterwards + frappe.respond_as_web_page("Quickbooks Authentication", html="") + + +class QuickBooksMigrator(Document): + def __init__(self, *args, **kwargs): + super(QuickBooksMigrator, self).__init__(*args, **kwargs) + from pprint import pprint + self.oauth = OAuth2Session( + client_id=self.client_id, + redirect_uri=self.redirect_url, + scope=self.scope + ) + if not self.authorization_url and self.authorization_endpoint: + self.authorization_url = self.oauth.authorization_url(self.authorization_endpoint)[0] + + + def on_update(self): + if self.company: + # We need a Cost Center corresponding to the selected erpnext Company + self.default_cost_center = frappe.db.get_value('Company', self.company, 'cost_center') + self.default_warehouse = frappe.get_all('Warehouse', filters={"company": self.company, "is_group": 0})[0]["name"] + if self.authorization_endpoint: + self.authorization_url = self.oauth.authorization_url(self.authorization_endpoint)[0] + + + def migrate(self): + frappe.enqueue_doc("QuickBooks Migrator", "QuickBooks Migrator", "_migrate", queue="long") + + + def _migrate(self): + try: + self.set_indicator("In Progress") + # Add quickbooks_id field to every document so that we can lookup by Id reference + # provided by documents in API responses. + # Also add a company field to Customer Supplier and Item + self._make_custom_fields() + + self._migrate_accounts() + + # Some Quickbooks Entities like Advance Payment, Payment aren't available firectly from API + # Sales Invoice also sometimes needs to be saved as a Journal Entry + # (When Item table is not present, This appens when Invoice is attached with a "StatementCharge" "ReimburseCharge + # Details of both of these cannot be fetched from API) + # Their GL entries need to be generated from GeneralLedger Report. + self._fetch_general_ledger() + + # QuickBooks data can have transactions that do not fall in existing fiscal years in ERPNext + self._create_fiscal_years() + + self._allow_fraction_in_unit() + + # Following entities are directly available from API + # Invoice can be an exception sometimes though (as explained above). + entities_for_normal_transform = [ + "Customer", "Item", "Vendor", + "Preferences", + "JournalEntry", "Purchase", "Deposit", + "Invoice", "CreditMemo", "SalesReceipt", "RefundReceipt", + "Bill", "VendorCredit", + "Payment", "BillPayment", + ] + for entity in entities_for_normal_transform: + self._migrate_entries(entity) + + # Following entries are not available directly from API, Need to be regenrated from GeneralLedger Report + entities_for_gl_transform = ["Advance Payment", "Tax Payment", "Sales Tax Payment", "Purchase Tax Payment", "Inventory Qty Adjust"] + for entity in entities_for_gl_transform: + self._migrate_entries_from_gl(entity) + self.set_indicator("Complete") + except Exception as e: + self.set_indicator("Failed") + self._log_error(e) + + frappe.db.commit() + + + def get_tokens(self): + token = self.oauth.fetch_token( + token_url=self.token_endpoint, + client_secret=self.client_secret, + code=self.code + ) + self.access_token = token["access_token"] + self.refresh_token = token["refresh_token"] + self.save() + + + def _refresh_tokens(self): + token = self.oauth.refresh_token( + token_url=self.token_endpoint, + client_id=self.client_id, + refresh_token=self.refresh_token, + client_secret=self.client_secret, + code=self.code + ) + self.access_token = token["access_token"] + self.refresh_token = token["refresh_token"] + self.save() + + + def _make_custom_fields(self): + doctypes_for_quickbooks_id_field = ["Account", "Customer", "Address", "Item", "Supplier", "Sales Invoice", "Journal Entry", "Purchase Invoice"] + for doctype in doctypes_for_quickbooks_id_field: + self._make_custom_quickbooks_id_field(doctype) + + doctypes_for_company_field = ["Customer", "Item", "Supplier"] + for doctype in doctypes_for_company_field: + self._make_custom_company_field(doctype) + + frappe.db.commit() + + + def _make_custom_quickbooks_id_field(self, doctype): + if not frappe.get_meta(doctype).has_field("quickbooks_id"): + frappe.get_doc({ + "doctype": "Custom Field", + "label": "QuickBooks ID", + "dt": doctype, + "fieldname": "quickbooks_id", + "fieldtype": "Data", + }).insert() + + + def _make_custom_company_field(self, doctype): + if not frappe.get_meta(doctype).has_field("company"): + frappe.get_doc({ + "doctype": "Custom Field", + "label": "Company", + "dt": doctype, + "fieldname": "company", + "fieldtype": "Link", + "options": "Company", + }).insert() + + + def _migrate_accounts(self): + self._make_root_accounts() + for entity in ["Account", "TaxRate", "TaxCode"]: + self._migrate_entries(entity) + + + def _make_root_accounts(self): + roots = ["Asset", "Equity", "Expense", "Liability", "Income"] + for root in roots: + try: + if not frappe.db.exists({"doctype": "Account", "name": encode_company_abbr("{} - QB".format(root), self.company), "company": self.company}): + frappe.get_doc({ + "doctype": "Account", + "account_name": "{} - QB".format(root), + "root_type": root, + "is_group": "1", + "company": self.company, + }).insert(ignore_mandatory=True) + except Exception as e: + self._log_error(e, root) + frappe.db.commit() + + + def _migrate_entries(self, entity): + try: + query_uri = "{}/company/{}/query".format( + self.api_endpoint, + self.quickbooks_company_id, + ) + max_result_count = 1000 + # Count number of entries + response = self._get(query_uri, + params={ + "query": """SELECT COUNT(*) FROM {}""".format(entity) + } + ) + entry_count = response.json()["QueryResponse"]["totalCount"] + + # fetch pages and accumulate + entries = [] + for start_position in range(1, entry_count + 1, max_result_count): + response = self._get(query_uri, + params={ + "query": """SELECT * FROM {} STARTPOSITION {} MAXRESULTS {}""".format( + entity, start_position, max_result_count + ) + } + ) + entries.extend(response.json()["QueryResponse"][entity]) + entries = self._preprocess_entries(entity, entries) + self._save_entries(entity, entries) + except Exception as e: + self._log_error(e, response.text) + + + def _fetch_general_ledger(self): + try: + query_uri = "{}/company/{}/reports/GeneralLedger".format(self.api_endpoint ,self.quickbooks_company_id) + response = self._get(query_uri, + params={ + "columns": ",".join(["tx_date", "txn_type", "credit_amt", "debt_amt"]), + "date_macro": "All", + "minorversion": 3, + } + ) + self.gl_entries = {} + for section in response.json()["Rows"]["Row"]: + if section["type"] == "Section": + self._get_gl_entries_from_section(section) + self.general_ledger = {} + for account in self.gl_entries.values(): + for line in account: + type_dict = self.general_ledger.setdefault(line["type"], {}) + if line["id"] not in type_dict: + type_dict[line["id"]] = { + "id": line["id"], + "date": line["date"], + "lines": [], + } + type_dict[line["id"]]["lines"].append(line) + except Exception as e: + self._log_error(e, response.text) + + + def _create_fiscal_years(self): + try: + # Assumes that exactly one fiscal year has been created so far + # Creates fiscal years till oldest ledger entry date is covered + from frappe.utils.data import add_years, getdate + from itertools import chain + smallest_ledger_entry_date = getdate(min(entry["date"] for entry in chain(*self.gl_entries.values()) if entry["date"])) + oldest_fiscal_year = frappe.get_all("Fiscal Year", + fields=["year_start_date", "year_end_date"], + order_by="year_start_date" + )[0] + # Keep on creating fiscal years + # until smallest_ledger_entry_date is no longer smaller than the oldest fiscal year's start date + while smallest_ledger_entry_date < oldest_fiscal_year.year_start_date: + new_fiscal_year = frappe.get_doc({"doctype": "Fiscal Year"}) + new_fiscal_year.year_start_date = add_years(oldest_fiscal_year.year_start_date, -1) + new_fiscal_year.year_end_date = add_years(oldest_fiscal_year.year_end_date, -1) + if new_fiscal_year.year_start_date.year == new_fiscal_year.year_end_date.year: + new_fiscal_year.year = new_fiscal_year.year_start_date.year + else: + new_fiscal_year.year = "{}-{}".format(new_fiscal_year.year_start_date.year, new_fiscal_year.year_end_date.year) + new_fiscal_year.save() + oldest_fiscal_year = new_fiscal_year + + frappe.db.commit() + except Exception as e: + self._log_error(e) + + + def _migrate_entries_from_gl(self, entity): + if entity in self.general_ledger: + self._save_entries(entity, self.general_ledger[entity].values()) + + + def _save_entries(self, entity, entries): + entity_method_map = { + "Account": self._save_account, + "TaxRate": self._save_tax_rate, + "TaxCode": self._save_tax_code, + + "Preferences": self._save_preference, + + "Customer": self._save_customer, + "Item": self._save_item, + "Vendor": self._save_vendor, + + "Invoice": self._save_invoice, + "CreditMemo": self._save_credit_memo, + "SalesReceipt": self._save_sales_receipt, + "RefundReceipt": self._save_refund_receipt, + + "JournalEntry": self._save_journal_entry, + + "Bill": self._save_bill, + "VendorCredit": self._save_vendor_credit, + + "Payment": self._save_payment, + "BillPayment": self._save_bill_payment, + + "Purchase": self._save_purchase, + "Deposit": self._save_deposit, + + "Advance Payment": self._save_advance_payment, + "Tax Payment": self._save_tax_payment, + "Sales Tax Payment": self._save_tax_payment, + "Purchase Tax Payment": self._save_tax_payment, + "Inventory Qty Adjust": self._save_inventory_qty_adjust, + } + total = len(entries) + for index, entry in enumerate(entries, start=1): + self._publish({"event": "progress", "message": _("Saving {0}").format(entity), "count": index, "total": total}) + entity_method_map[entity](entry) + frappe.db.commit() + + + def _preprocess_entries(self, entity, entries): + entity_method_map = { + "Account": self._preprocess_accounts, + "TaxRate": self._preprocess_tax_rates, + "TaxCode": self._preprocess_tax_codes, + } + preprocessor = entity_method_map.get(entity) + if preprocessor: + entries = preprocessor(entries) + return entries + + + def _get_gl_entries_from_section(self, section, account=None): + if "Header" in section: + if "id" in section["Header"]["ColData"][0]: + account = self._get_account_name_by_id(section["Header"]["ColData"][0]["id"]) + elif "value" in section["Header"]["ColData"][0] and section["Header"]["ColData"][0]["value"]: + # For some reason during migrating UK company, account id is not available. + # preprocess_accounts retains name:account mapping in self.accounts + # This mapping can then be used to obtain quickbooks_id for correspondong account + # Rest is trivial + + # Some Lines in General Leder Report are shown under Not Specified + # These should be skipped + if section["Header"]["ColData"][0]["value"] == "Not Specified": + return + account_id = self.accounts[section["Header"]["ColData"][0]["value"]]["Id"] + account = self._get_account_name_by_id(account_id) + entries = [] + for row in section["Rows"]["Row"]: + if row["type"] == "Data": + data = row["ColData"] + entries.append({ + "account": account, + "date": data[0]["value"], + "type": data[1]["value"], + "id": data[1].get("id"), + "credit": frappe.utils.flt(data[2]["value"]), + "debit": frappe.utils.flt(data[3]["value"]), + }) + if row["type"] == "Section": + self._get_gl_entries_from_section(row, account) + self.gl_entries.setdefault(account, []).extend(entries) + + + def _preprocess_accounts(self, accounts): + self.accounts = {account["Name"]: account for account in accounts} + for account in accounts: + if any(acc["SubAccount"] and acc["ParentRef"]["value"] == account["Id"] for acc in accounts): + account["is_group"] = 1 + else: + account["is_group"] = 0 + return sorted(accounts, key=lambda account: int(account["Id"])) + + + def _save_account(self, account): + mapping = { + "Bank": "Asset", + "Other Current Asset": "Asset", + "Fixed Asset": "Asset", + "Other Asset": "Asset", + "Accounts Receivable": "Asset", + + "Equity": "Equity", + + "Expense": "Expense", + "Other Expense": "Expense", + "Cost of Goods Sold": "Expense", + + "Accounts Payable": "Liability", + "Credit Card": "Liability", + "Long Term Liability": "Liability", + "Other Current Liability": "Liability", + + "Income": "Income", + "Other Income": "Income", + } + # Map Quickbooks Account Types to ERPNext root_accunts and and root_type + try: + if not frappe.db.exists({"doctype": "Account", "quickbooks_id": account["Id"], "company": self.company}): + is_child = account["SubAccount"] + is_group = account["is_group"] + # Create Two Accounts for every Group Account + if is_group: + account_id = "Group - {}".format(account["Id"]) + else: + account_id = account["Id"] + + if is_child: + parent_account = self._get_account_name_by_id("Group - {}".format(account["ParentRef"]["value"])) + else: + parent_account = encode_company_abbr("{} - QB".format(mapping[account["AccountType"]]), self.company) + + frappe.get_doc({ + "doctype": "Account", + "quickbooks_id": account_id, + "account_name": self._get_unique_account_name(account["Name"]), + "root_type": mapping[account["AccountType"]], + "account_type": self._get_account_type(account), + "account_currency": account["CurrencyRef"]["value"], + "parent_account": parent_account, + "is_group": is_group, + "company": self.company, + }).insert() + + if is_group: + # Create a Leaf account corresponding to the group account + frappe.get_doc({ + "doctype": "Account", + "quickbooks_id": account["Id"], + "account_name": self._get_unique_account_name(account["Name"]), + "root_type": mapping[account["AccountType"]], + "account_type": self._get_account_type(account), + "account_currency": account["CurrencyRef"]["value"], + "parent_account": self._get_account_name_by_id(account_id), + "is_group": 0, + "company": self.company, + }).insert() + if account.get("AccountSubType") == "UndepositedFunds": + self.undeposited_funds_account = self._get_account_name_by_id(account["Id"]) + self.save() + except Exception as e: + self._log_error(e, account) + + + def _get_account_type(self, account): + account_subtype_mapping = {"UndepositedFunds": "Cash"} + account_type = account_subtype_mapping.get(account.get("AccountSubType")) + if account_type is None: + account_type_mapping = {"Accounts Payable": "Payable", "Accounts Receivable": "Receivable", "Bank": "Bank", "Credit Card": "Bank"} + account_type = account_type_mapping.get(account["AccountType"]) + return account_type + + + def _preprocess_tax_rates(self, tax_rates): + self.tax_rates = {tax_rate["Id"]: tax_rate for tax_rate in tax_rates} + return tax_rates + + + def _save_tax_rate(self, tax_rate): + try: + if not frappe.db.exists({"doctype": "Account", "quickbooks_id": "TaxRate - {}".format(tax_rate["Id"]), "company": self.company}): + frappe.get_doc({ + "doctype": "Account", + "quickbooks_id": "TaxRate - {}".format(tax_rate["Id"]), + "account_name": "{} - QB".format(tax_rate["Name"]), + "root_type": "Liability", + "parent_account": encode_company_abbr("{} - QB".format("Liability"), self.company), + "is_group": "0", + "company": self.company, + }).insert() + except Exception as e: + self._log_error(e, tax_rate) + + + def _preprocess_tax_codes(self, tax_codes): + self.tax_codes = {tax_code["Id"]: tax_code for tax_code in tax_codes} + return tax_codes + + + def _save_tax_code(self, tax_code): + pass + + + def _save_customer(self, customer): + try: + if not frappe.db.exists({"doctype": "Customer", "quickbooks_id": customer["Id"], "company": self.company}): + try: + receivable_account = frappe.get_all("Account", filters={ + "account_type": "Receivable", + "account_currency": customer["CurrencyRef"]["value"], + "company": self.company, + })[0]["name"] + except Exception as e: + receivable_account = None + erpcustomer = frappe.get_doc({ + "doctype": "Customer", + "quickbooks_id": customer["Id"], + "customer_name" : encode_company_abbr(customer["DisplayName"], self.company), + "customer_type" : "Individual", + "customer_group" : "Commercial", + "default_currency": customer["CurrencyRef"]["value"], + "accounts": [{"company": self.company, "account": receivable_account}], + "territory" : "All Territories", + "company": self.company, + }).insert() + if "BillAddr" in customer: + self._create_address(erpcustomer, "Customer", customer["BillAddr"], "Billing") + if "ShipAddr" in customer: + self._create_address(erpcustomer, "Customer", customer["ShipAddr"], "Shipping") + except Exception as e: + self._log_error(e, customer) + + + def _save_item(self, item): + try: + if not frappe.db.exists({"doctype": "Item", "quickbooks_id": item["Id"], "company": self.company}): + if item["Type"] in ("Service", "Inventory"): + item_dict = { + "doctype": "Item", + "quickbooks_id": item["Id"], + "item_code" : encode_company_abbr(item["Name"], self.company), + "stock_uom": "Unit", + "is_stock_item": 0, + "item_group": "All Item Groups", + "company": self.company, + "item_defaults": [{"company": self.company, "default_warehouse": self.default_warehouse}] + } + if "ExpenseAccountRef" in item: + expense_account = self._get_account_name_by_id(item["ExpenseAccountRef"]["value"]) + item_dict["item_defaults"][0]["expense_account"] = expense_account + if "IncomeAccountRef" in item: + income_account = self._get_account_name_by_id(item["IncomeAccountRef"]["value"]) + item_dict["item_defaults"][0]["income_account"] = income_account + frappe.get_doc(item_dict).insert() + except Exception as e: + self._log_error(e, item) + + + def _allow_fraction_in_unit(self): + frappe.db.set_value("UOM", "Unit", "must_be_whole_number", 0) + + + def _save_vendor(self, vendor): + try: + if not frappe.db.exists({"doctype": "Supplier", "quickbooks_id": vendor["Id"], "company": self.company}): + erpsupplier = frappe.get_doc({ + "doctype": "Supplier", + "quickbooks_id": vendor["Id"], + "supplier_name" : encode_company_abbr(vendor["DisplayName"], self.company), + "supplier_group" : "All Supplier Groups", + "company": self.company, + }).insert() + if "BillAddr" in vendor: + self._create_address(erpsupplier, "Supplier", vendor["BillAddr"], "Billing") + if "ShipAddr" in vendor: + self._create_address(erpsupplier, "Supplier",vendor["ShipAddr"], "Shipping") + except Exception as e: + self._log_error(e) + + + def _save_preference(self, preference): + try: + if preference["SalesFormsPrefs"]["AllowShipping"]: + default_shipping_account_id = preference["SalesFormsPrefs"]["DefaultShippingAccount"] + self.default_shipping_account = self._get_account_name_by_id(self, default_shipping_account_id) + self.save() + except Exception as e: + self._log_error(e, preference) + + + def _save_invoice(self, invoice): + # Invoice can be Linked with Another Transactions + # If any of these transactions is a "StatementCharge" or "ReimburseCharge" then in the UI + # item list is populated from the corresponding transaction, these items are not shown in api response + # Also as of now there is no way of fetching the corresponding transaction from api + # We in order to correctly reflect account balance make an equivalent Journal Entry + quickbooks_id = "Invoice - {}".format(invoice["Id"]) + if any(linked["TxnType"] in ("StatementCharge", "ReimburseCharge") for linked in invoice["LinkedTxn"]): + self._save_invoice_as_journal_entry(invoice, quickbooks_id) + else: + self._save_sales_invoice(invoice, quickbooks_id) + + + def _save_credit_memo(self, credit_memo): + # Credit Memo is equivalent to a return Sales Invoice + quickbooks_id = "Credit Memo - {}".format(credit_memo["Id"]) + self._save_sales_invoice(credit_memo, quickbooks_id, is_return=True) + + + def _save_sales_receipt(self, sales_receipt): + # Sales Receipt is equivalent to a POS Sales Invoice + quickbooks_id = "Sales Receipt - {}".format(sales_receipt["Id"]) + self._save_sales_invoice(sales_receipt, quickbooks_id, is_pos=True) + + + def _save_refund_receipt(self, refund_receipt): + # Refund Receipt is equivalent to a return POS Sales Invoice + quickbooks_id = "Refund Receipt - {}".format(refund_receipt["Id"]) + self._save_sales_invoice(refund_receipt, quickbooks_id, is_return=True, is_pos=True) + + + def _save_sales_invoice(self, invoice, quickbooks_id, is_return=False, is_pos=False): + try: + if not frappe.db.exists({"doctype": "Sales Invoice", "quickbooks_id": quickbooks_id, "company": self.company}): + invoice_dict = { + "doctype": "Sales Invoice", + "quickbooks_id": quickbooks_id, + + # Quickbooks uses ISO 4217 Code + # of course this gonna come back to bite me + "currency": invoice["CurrencyRef"]["value"], + + # Exchange Rate is provided if multicurrency is enabled + # It is not provided if multicurrency is not enabled + "conversion_rate": invoice.get("ExchangeRate", 1), + "posting_date": invoice["TxnDate"], + + # QuickBooks doesn't make Due Date a mandatory field this is a hack + "due_date": invoice.get("DueDate", invoice["TxnDate"]), + "customer": frappe.get_all("Customer", + filters={ + "quickbooks_id": invoice["CustomerRef"]["value"], + "company": self.company, + })[0]["name"], + "items": self._get_si_items(invoice, is_return=is_return), + "taxes": self._get_taxes(invoice), + + # Do not change posting_date upon submission + "set_posting_time": 1, + + # QuickBooks doesn't round total + "disable_rounded_total": 1, + "is_return": is_return, + "is_pos": is_pos, + "payments": self._get_invoice_payments(invoice, is_return=is_return, is_pos=is_pos), + "company": self.company, + } + discount = self._get_discount(invoice["Line"]) + if discount: + if invoice["ApplyTaxAfterDiscount"]: + invoice_dict["apply_discount_on"] = "Net Total" + else: + invoice_dict["apply_discount_on"] = "Grand Total" + invoice_dict["discount_amount"] = discount["Amount"] + + invoice_doc = frappe.get_doc(invoice_dict) + invoice_doc.insert() + invoice_doc.submit() + except Exception as e: + self._log_error(e, [invoice, invoice_dict, json.loads(invoice_doc.as_json())]) + + + def _get_si_items(self, invoice, is_return=False): + items = [] + for line in invoice["Line"]: + if line["DetailType"] == "SalesItemLineDetail": + if line["SalesItemLineDetail"]["TaxCodeRef"]["value"] != "TAX": + tax_code = line["SalesItemLineDetail"]["TaxCodeRef"]["value"] + else: + if "TxnTaxCodeRef" in invoice["TxnTaxDetail"]: + tax_code = invoice["TxnTaxDetail"]["TxnTaxCodeRef"]["value"] + else: + tax_code = "NON" + if line["SalesItemLineDetail"]["ItemRef"]["value"] != "SHIPPING_ITEM_ID": + item = frappe.db.get_all("Item", + filters={ + "quickbooks_id": line["SalesItemLineDetail"]["ItemRef"]["value"], + "company": self.company, + }, + fields=["name", "stock_uom"] + )[0] + items.append({ + "item_code": item["name"], + "conversion_factor": 1, + "uom": item["stock_uom"], + "description": line.get("Description", line["SalesItemLineDetail"]["ItemRef"]["name"]), + "qty": line["SalesItemLineDetail"]["Qty"], + "price_list_rate": line["SalesItemLineDetail"]["UnitPrice"], + "cost_center": self.default_cost_center, + "warehouse": self.default_warehouse, + "item_tax_rate": json.dumps(self._get_item_taxes(tax_code)) + }) + else: + items.append({ + "item_name": "Shipping", + "conversion_factor": 1, + "expense_account": self._get_account_name_by_id("TaxRate - {}".format(line["SalesItemLineDetail"]["TaxCodeRef"]["value"])), + "uom": "Unit", + "description": "Shipping", + "income_account": self.default_shipping_account, + "qty": 1, + "price_list_rate": line["Amount"], + "cost_center": self.default_cost_center, + "warehouse": self.default_warehouse, + "item_tax_rate": json.dumps(self._get_item_taxes(tax_code)) + }) + if is_return: + items[-1]["qty"] *= -1 + elif line["DetailType"] == "DescriptionOnly": + items[-1].update({ + "margin_type": "Percentage", + "margin_rate_or_amount": int(line["Description"].split("%")[0]), + }) + return items + + + def _get_item_taxes(self, tax_code): + tax_rates = self.tax_rates + item_taxes = {} + if tax_code != "NON": + tax_code = self.tax_codes[tax_code] + for rate_list_type in ("SalesTaxRateList", "PurchaseTaxRateList"): + if rate_list_type in tax_code: + for tax_rate_detail in tax_code[rate_list_type]["TaxRateDetail"]: + if tax_rate_detail["TaxTypeApplicable"] == "TaxOnAmount": + tax_head = self._get_account_name_by_id("TaxRate - {}".format(tax_rate_detail["TaxRateRef"]["value"])) + tax_rate = tax_rates[tax_rate_detail["TaxRateRef"]["value"]] + item_taxes[tax_head] = tax_rate["RateValue"] + return item_taxes + + + def _get_invoice_payments(self, invoice, is_return=False, is_pos=False): + if is_pos: + amount = invoice["TotalAmt"] + if is_return: + amount = -amount + return [{ + "mode_of_payment": "Cash", + "account": self._get_account_name_by_id(invoice["DepositToAccountRef"]["value"]), + "amount": amount, + }] + + + def _get_discount(self, lines): + for line in lines: + if line["DetailType"] == "DiscountLineDetail" and "Amount" in line["DiscountLineDetail"]: + return line + + + def _save_invoice_as_journal_entry(self, invoice, quickbooks_id): + try: + accounts = [] + for line in self.general_ledger["Invoice"][invoice["Id"]]["lines"]: + account_line = {"account": line["account"], "cost_center": self.default_cost_center} + if line["debit"]: + account_line["debit_in_account_currency"] = line["debit"] + elif line["credit"]: + account_line["credit_in_account_currency"] = line["credit"] + if frappe.db.get_value("Account", line["account"], "account_type") == "Receivable": + account_line["party_type"] = "Customer" + account_line["party"] = frappe.get_all("Customer", + filters={"quickbooks_id": invoice["CustomerRef"]["value"], "company": self.company} + )[0]["name"] + + accounts.append(account_line) + + posting_date = invoice["TxnDate"] + self.__save_journal_entry(quickbooks_id, accounts, posting_date) + except Exception as e: + self._log_error(e, [invoice, accounts]) + + + def _save_journal_entry(self, journal_entry): + # JournalEntry is equivalent to a Journal Entry + + def _get_je_accounts(lines): + # Converts JounalEntry lines to accounts list + posting_type_field_mapping = { + "Credit": "credit_in_account_currency", + "Debit": "debit_in_account_currency", + } + accounts = [] + for line in lines: + if line["DetailType"] == "JournalEntryLineDetail": + account_name = self._get_account_name_by_id(line["JournalEntryLineDetail"]["AccountRef"]["value"]) + posting_type = line["JournalEntryLineDetail"]["PostingType"] + accounts.append({ + "account": account_name, + posting_type_field_mapping[posting_type]: line["Amount"], + "cost_center": self.default_cost_center, + }) + return accounts + + quickbooks_id = "Journal Entry - {}".format(journal_entry["Id"]) + accounts = _get_je_accounts(journal_entry["Line"]) + posting_date = journal_entry["TxnDate"] + self.__save_journal_entry(quickbooks_id, accounts, posting_date) + + + def __save_journal_entry(self, quickbooks_id, accounts, posting_date): + try: + if not frappe.db.exists({"doctype": "Journal Entry", "quickbooks_id": quickbooks_id, "company": self.company}): + je = frappe.get_doc({ + "doctype": "Journal Entry", + "quickbooks_id": quickbooks_id, + "company": self.company, + "posting_date": posting_date, + "accounts": accounts, + "multi_currency": 1, + }) + je.insert() + je.submit() + except Exception as e: + self._log_error(e, [accounts, json.loads(je.as_json())]) + + + def _save_bill(self, bill): + # Bill is equivalent to a Purchase Invoice + quickbooks_id = "Bill - {}".format(bill["Id"]) + self.__save_purchase_invoice(bill, quickbooks_id) + + + def _save_vendor_credit(self, vendor_credit): + # Vendor Credit is equivalent to a return Purchase Invoice + quickbooks_id = "Vendor Credit - {}".format(vendor_credit["Id"]) + self.__save_purchase_invoice(vendor_credit, quickbooks_id, is_return=True) + + + def __save_purchase_invoice(self, invoice, quickbooks_id, is_return=False): + try: + if not frappe.db.exists({"doctype": "Purchase Invoice", "quickbooks_id": quickbooks_id, "company": self.company}): + credit_to_account = self._get_account_name_by_id(invoice["APAccountRef"]["value"]) + invoice_dict = { + "doctype": "Purchase Invoice", + "quickbooks_id": quickbooks_id, + "currency": invoice["CurrencyRef"]["value"], + "conversion_rate": invoice.get("ExchangeRate", 1), + "posting_date": invoice["TxnDate"], + "due_date": invoice.get("DueDate", invoice["TxnDate"]), + "credit_to": credit_to_account, + "supplier": frappe.get_all("Supplier", + filters={ + "quickbooks_id": invoice["VendorRef"]["value"], + "company": self.company, + })[0]["name"], + "items": self._get_pi_items(invoice, is_return=is_return), + "taxes": self._get_taxes(invoice), + "set_posting_time": 1, + "disable_rounded_total": 1, + "is_return": is_return, + "udpate_stock": 0, + "company": self.company, + } + invoice_doc = frappe.get_doc(invoice_dict) + invoice_doc.insert() + invoice_doc.submit() + except Exception as e: + self._log_error(e, [invoice, invoice_dict, json.loads(invoice_doc.as_json())]) + + + def _get_pi_items(self, purchase_invoice, is_return=False): + items = [] + for line in purchase_invoice["Line"]: + if line["DetailType"] == "ItemBasedExpenseLineDetail": + if line["ItemBasedExpenseLineDetail"]["TaxCodeRef"]["value"] != "TAX": + tax_code = line["ItemBasedExpenseLineDetail"]["TaxCodeRef"]["value"] + else: + if "TxnTaxCodeRef" in purchase_invoice["TxnTaxDetail"]: + tax_code = purchase_invoice["TxnTaxDetail"]["TxnTaxCodeRef"]["value"] + else: + tax_code = "NON" + item = frappe.db.get_all("Item", + filters={ + "quickbooks_id": line["ItemBasedExpenseLineDetail"]["ItemRef"]["value"], + "company": self.company + }, + fields=["name", "stock_uom"] + )[0] + items.append({ + "item_code": item["name"], + "conversion_factor": 1, + "uom": item["stock_uom"], + "description": line.get("Description", line["ItemBasedExpenseLineDetail"]["ItemRef"]["name"]), + "qty": line["ItemBasedExpenseLineDetail"]["Qty"], + "price_list_rate": line["ItemBasedExpenseLineDetail"]["UnitPrice"], + "warehouse": self.default_warehouse, + "cost_center": self.default_cost_center, + "item_tax_rate": json.dumps(self._get_item_taxes(tax_code)), + }) + elif line["DetailType"] == "AccountBasedExpenseLineDetail": + if line["AccountBasedExpenseLineDetail"]["TaxCodeRef"]["value"] != "TAX": + tax_code = line["AccountBasedExpenseLineDetail"]["TaxCodeRef"]["value"] + else: + if "TxnTaxCodeRef" in purchase_invoice["TxnTaxDetail"]: + tax_code = purchase_invoice["TxnTaxDetail"]["TxnTaxCodeRef"]["value"] + else: + tax_code = "NON" + items.append({ + "item_name": line.get("Description", line["AccountBasedExpenseLineDetail"]["AccountRef"]["name"]), + "conversion_factor": 1, + "expense_account": self._get_account_name_by_id(line["AccountBasedExpenseLineDetail"]["AccountRef"]["value"]), + "uom": "Unit", + "description": line.get("Description", line["AccountBasedExpenseLineDetail"]["AccountRef"]["name"]), + "qty": 1, + "price_list_rate": line["Amount"], + "warehouse": self.default_warehouse, + "cost_center": self.default_cost_center, + "item_tax_rate": json.dumps(self._get_item_taxes(tax_code)), + }) + if is_return: + items[-1]["qty"] *= -1 + return items + + + def _save_payment(self, payment): + try: + quickbooks_id = "Payment - {}".format(payment["Id"]) + # If DepositToAccountRef is not set on payment that means it actually doesn't affect any accounts + # No need to record such payment + # Such payment record is created QuickBooks Payments API + if "DepositToAccountRef" not in payment: + return + + # A Payment can be linked to multiple transactions + accounts = [] + for line in payment["Line"]: + linked_transaction = line["LinkedTxn"][0] + if linked_transaction["TxnType"] == "Invoice": + si_quickbooks_id = "Invoice - {}".format(linked_transaction["TxnId"]) + # Invoice could have been saved as a Sales Invoice or a Journal Entry + if frappe.db.exists({"doctype": "Sales Invoice", "quickbooks_id": si_quickbooks_id, "company": self.company}): + sales_invoice = frappe.get_all("Sales Invoice", + filters={ + "quickbooks_id": si_quickbooks_id, + "company": self.company, + }, + fields=["name", "customer", "debit_to"], + )[0] + reference_type = "Sales Invoice" + reference_name = sales_invoice["name"] + party = sales_invoice["customer"] + party_account = sales_invoice["debit_to"] + + if frappe.db.exists({"doctype": "Journal Entry", "quickbooks_id": si_quickbooks_id, "company": self.company}): + journal_entry = frappe.get_doc("Journal Entry", + { + "quickbooks_id": si_quickbooks_id, + "company": self.company, + } + ) + # Invoice saved as a Journal Entry must have party and party_type set on line containing Receivable Account + customer_account_line = list(filter(lambda acc: acc.party_type == "Customer", journal_entry.accounts))[0] + + reference_type = "Journal Entry" + reference_name = journal_entry.name + party = customer_account_line.party + party_account = customer_account_line.account + + accounts.append({ + "party_type": "Customer", + "party": party, + "reference_type": reference_type, + "reference_name": reference_name, + "account": party_account, + "credit_in_account_currency": line["Amount"], + "cost_center": self.default_cost_center, + }) + + deposit_account = self._get_account_name_by_id(payment["DepositToAccountRef"]["value"]) + accounts.append({ + "account": deposit_account, + "debit_in_account_currency": payment["TotalAmt"], + "cost_center": self.default_cost_center, + }) + posting_date = payment["TxnDate"] + self.__save_journal_entry(quickbooks_id, accounts, posting_date) + except Exception as e: + self._log_error(e, [payment, accounts]) + + + def _save_bill_payment(self, bill_payment): + try: + quickbooks_id = "BillPayment - {}".format(bill_payment["Id"]) + # A BillPayment can be linked to multiple transactions + accounts = [] + for line in bill_payment["Line"]: + linked_transaction = line["LinkedTxn"][0] + if linked_transaction["TxnType"] == "Bill": + pi_quickbooks_id = "Bill - {}".format(linked_transaction["TxnId"]) + if frappe.db.exists({"doctype": "Purchase Invoice", "quickbooks_id": pi_quickbooks_id, "company": self.company}): + purchase_invoice = frappe.get_all("Purchase Invoice", + filters={ + "quickbooks_id": pi_quickbooks_id, + "company": self.company, + }, + fields=["name", "supplier", "credit_to"], + )[0] + reference_type = "Purchase Invoice" + reference_name = purchase_invoice["name"] + party = purchase_invoice["supplier"] + party_account = purchase_invoice["credit_to"] + accounts.append({ + "party_type": "Supplier", + "party": party, + "reference_type": reference_type, + "reference_name": reference_name, + "account": party_account, + "debit_in_account_currency": line["Amount"], + "cost_center": self.default_cost_center, + }) + + if bill_payment["PayType"] == "Check": + bank_account_id = bill_payment["CheckPayment"]["BankAccountRef"]["value"] + elif bill_payment["PayType"] == "CreditCard": + bank_account_id = bill_payment["CreditCardPayment"]["CCAccountRef"]["value"] + + bank_account = self._get_account_name_by_id(bank_account_id) + accounts.append({ + "account": bank_account, + "credit_in_account_currency": bill_payment["TotalAmt"], + "cost_center": self.default_cost_center, + }) + posting_date = bill_payment["TxnDate"] + self.__save_journal_entry(quickbooks_id, accounts, posting_date) + except Exception as e: + self._log_error(e, [bill_payment, accounts]) + + + def _save_purchase(self, purchase): + try: + quickbooks_id = "Purchase - {}".format(purchase["Id"]) + # Credit Bank Account + accounts = [{ + "account": self._get_account_name_by_id(purchase["AccountRef"]["value"]), + "credit_in_account_currency": purchase["TotalAmt"], + "cost_center": self.default_cost_center, + }] + + # Debit Mentioned Accounts + for line in purchase["Line"]: + if line["DetailType"] == "AccountBasedExpenseLineDetail": + account = self._get_account_name_by_id(line["AccountBasedExpenseLineDetail"]["AccountRef"]["value"]) + elif line["DetailType"] == "ItemBasedExpenseLineDetail": + account = frappe.get_doc("Item", + {"quickbooks_id": line["ItemBasedExpenseLineDetail"]["ItemRef"]["value"], "company": self.company} + ).item_defaults[0].expense_account + accounts.append({ + "account": account, + "debit_in_account_currency": line["Amount"], + "cost_center": self.default_cost_center, + }) + + # Debit Tax Accounts + if "TxnTaxDetail" in purchase: + for line in purchase["TxnTaxDetail"]["TaxLine"]: + accounts.append({ + "account": self._get_account_name_by_id("TaxRate - {}".format(line["TaxLineDetail"]["TaxRateRef"]["value"])), + "debit_in_account_currency": line["Amount"], + "cost_center": self.default_cost_center, + }) + + # If purchase["Credit"] is set to be True then it represents a refund + if purchase.get("Credit"): + for account in accounts: + if "debit_in_account_currency" in account: + account["credit_in_account_currency"] = account["debit_in_account_currency"] + del account["debit_in_account_currency"] + else: + account["debit_in_account_currency"] = account["credit_in_account_currency"] + del account["credit_in_account_currency"] + + posting_date = purchase["TxnDate"] + self.__save_journal_entry(quickbooks_id, accounts, posting_date) + except Exception as e: + self._log_error(e, [purchase, accounts]) + + + def _save_deposit(self, deposit): + try: + quickbooks_id = "Deposit - {}".format(deposit["Id"]) + # Debit Bank Account + accounts = [{ + "account": self._get_account_name_by_id(deposit["DepositToAccountRef"]["value"]), + "debit_in_account_currency": deposit["TotalAmt"], + "cost_center": self.default_cost_center, + }] + + # Credit Mentioned Accounts + for line in deposit["Line"]: + if "LinkedTxn" in line: + accounts.append({ + "account": self.undeposited_funds_account, + "credit_in_account_currency": line["Amount"], + "cost_center": self.default_cost_center, + }) + else: + accounts.append({ + "account": self._get_account_name_by_id(line["DepositLineDetail"]["AccountRef"]["value"]), + "credit_in_account_currency": line["Amount"], + "cost_center": self.default_cost_center, + }) + + # Debit Cashback if mentioned + if "CashBack" in deposit: + accounts.append({ + "account": self._get_account_name_by_id(deposit["CashBack"]["AccountRef"]["value"]), + "debit_in_account_currency": deposit["CashBack"]["Amount"], + "cost_center": self.default_cost_center, + }) + + posting_date = deposit["TxnDate"] + self.__save_journal_entry(quickbooks_id, accounts, posting_date) + except Exception as e: + self._log_error(e, [deposit, accounts]) + + + def _save_advance_payment(self, advance_payment): + quickbooks_id = "Advance Payment - {}".format(advance_payment["id"]) + self.__save_ledger_entry_as_je(advance_payment, quickbooks_id) + + + def _save_tax_payment(self, tax_payment): + quickbooks_id = "Tax Payment - {}".format(tax_payment["id"]) + self.__save_ledger_entry_as_je(tax_payment, quickbooks_id) + + + def _save_inventory_qty_adjust(self, inventory_qty_adjust): + quickbooks_id = "Inventory Qty Adjust - {}".format(inventory_qty_adjust["id"]) + self.__save_ledger_entry_as_je(inventory_qty_adjust, quickbooks_id) + + + def __save_ledger_entry_as_je(self, ledger_entry, quickbooks_id): + try: + accounts = [] + for line in ledger_entry["lines"]: + account_line = {"account": line["account"], "cost_center": self.default_cost_center} + if line["credit"]: + account_line["credit_in_account_currency"] = line["credit"] + else: + account_line["debit_in_account_currency"] = line["debit"] + accounts.append(account_line) + + posting_date = ledger_entry["date"] + self.__save_journal_entry(quickbooks_id, accounts, posting_date) + except Exception as e: + self._log_error(e, ledger_entry) + + + def _get_taxes(self, entry): + taxes = [] + if "TxnTaxDetail" not in entry or "TaxLine" not in entry["TxnTaxDetail"]: + return taxes + for line in entry["TxnTaxDetail"]["TaxLine"]: + tax_rate = line["TaxLineDetail"]["TaxRateRef"]["value"] + account_head = self._get_account_name_by_id("TaxRate - {}".format(tax_rate)) + tax_type_applicable = self._get_tax_type(tax_rate) + if tax_type_applicable == "TaxOnAmount": + taxes.append({ + "charge_type": "On Net Total", + "account_head": account_head, + "description": account_head, + "cost_center": self.default_cost_center, + "rate": 0, + }) + else: + parent_tax_rate = self._get_parent_tax_rate(tax_rate) + parent_row_id = self._get_parent_row_id(parent_tax_rate, taxes) + taxes.append({ + "charge_type": "On Previous Row Amount", + "row_id": parent_row_id, + "account_head": account_head, + "description": account_head, + "cost_center": self.default_cost_center, + "rate": line["TaxLineDetail"]["TaxPercent"], + }) + return taxes + + + def _get_tax_type(self, tax_rate): + for tax_code in self.tax_codes.values(): + for rate_list_type in ("SalesTaxRateList", "PurchaseTaxRateList"): + if rate_list_type in tax_code: + for tax_rate_detail in tax_code[rate_list_type]["TaxRateDetail"]: + if tax_rate_detail["TaxRateRef"]["value"] == tax_rate: + return tax_rate_detail["TaxTypeApplicable"] + + + def _get_parent_tax_rate(self, tax_rate): + parent = None + for tax_code in self.tax_codes.values(): + for rate_list_type in ("SalesTaxRateList", "PurchaseTaxRateList"): + if rate_list_type in tax_code: + for tax_rate_detail in tax_code[rate_list_type]["TaxRateDetail"]: + if tax_rate_detail["TaxRateRef"]["value"] == tax_rate: + parent = tax_rate_detail["TaxOnTaxOrder"] + if parent: + for tax_rate_detail in tax_code[rate_list_type]["TaxRateDetail"]: + if tax_rate_detail["TaxOrder"] == parent: + return tax_rate_detail["TaxRateRef"]["value"] + + + def _get_parent_row_id(self, tax_rate, taxes): + tax_account = self._get_account_name_by_id("TaxRate - {}".format(tax_rate)) + for index, tax in enumerate(taxes): + if tax["account_head"] == tax_account: + return index + 1 + + + def _create_address(self, entity, doctype, address, address_type): + try : + if not frappe.db.exists({"doctype": "Address", "quickbooks_id": address["Id"]}): + frappe.get_doc({ + "doctype": "Address", + "quickbooks_address_id": address["Id"], + "address_title": entity.name, + "address_type": address_type, + "address_line1": address["Line1"], + "city": address["City"], + "links": [{"link_doctype": doctype, "link_name": entity.name}] + }).insert() + except Exception as e: + self._log_error(e, address) + + + def _get(self, *args, **kwargs): + kwargs["headers"] = { + "Accept": "application/json", + "Authorization": "Bearer {}".format(self.access_token) + } + response = requests.get(*args, **kwargs) + # HTTP Status code 401 here means that the access_token is expired + # We can refresh tokens and retry + # However limitless recursion does look dangerous + if response.status_code == 401: + self._refresh_tokens() + response = self._get(*args, **kwargs) + return response + + + def _get_account_name_by_id(self, quickbooks_id): + return frappe.get_all("Account", filters={"quickbooks_id": quickbooks_id, "company": self.company})[0]["name"] + + + def _publish(self, *args, **kwargs): + frappe.publish_realtime("quickbooks_progress_update", *args, **kwargs) + + + def _get_unique_account_name(self, quickbooks_name, number=0): + if number: + quickbooks_account_name = "{} - {} - QB".format(quickbooks_name, number) + else: + quickbooks_account_name = "{} - QB".format(quickbooks_name) + company_encoded_account_name = encode_company_abbr(quickbooks_account_name, self.company) + if frappe.db.exists({"doctype": "Account", "name": company_encoded_account_name, "company": self.company}): + unique_account_name = self._get_unique_account_name(quickbooks_name, number + 1) + else: + unique_account_name = quickbooks_account_name + return unique_account_name + + + def _log_error(self, execption, data=""): + import json, traceback + traceback.print_exc() + frappe.log_error(title="QuickBooks Migration Error", + message="\n".join([ + "Data", + json.dumps(data, + sort_keys=True, + indent=4, + separators=(',', ': ') + ), + "Exception", + traceback.format_exc() + ]) + ) + + + def set_indicator(self, status): + self.status = status + self.save() + frappe.db.commit() diff --git a/erpnext/erpnext_integrations/doctype/quickbooks_migrator/test_quickbooks_migrator.js b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/test_quickbooks_migrator.js new file mode 100644 index 0000000000..b71d704807 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/test_quickbooks_migrator.js @@ -0,0 +1,23 @@ +/* eslint-disable */ +// rename this file from _test_[name] to test_[name] to activate +// and remove above this line + +QUnit.test("test: QuickBooks Migrator", function (assert) { + let done = assert.async(); + + // number of asserts + assert.expect(1); + + frappe.run_serially([ + // insert a new QuickBooks Migrator + () => frappe.tests.make('QuickBooks Migrator', [ + // values to be set + {key: 'value'} + ]), + () => { + assert.equal(cur_frm.doc.key, 'value'); + }, + () => done() + ]); + +}); diff --git a/erpnext/erpnext_integrations/doctype/quickbooks_migrator/test_quickbooks_migrator.py b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/test_quickbooks_migrator.py new file mode 100644 index 0000000000..6ce7c92ae8 --- /dev/null +++ b/erpnext/erpnext_integrations/doctype/quickbooks_migrator/test_quickbooks_migrator.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and Contributors +# See license.txt +from __future__ import unicode_literals + +import frappe +import unittest + +class TestQuickBooksMigrator(unittest.TestCase): + pass