Merge branch 'develop' into exotel-fixes

This commit is contained in:
Ankush Menat
2022-03-28 19:55:39 +05:30
1641 changed files with 98266 additions and 70889 deletions

View File

@@ -12,26 +12,30 @@ def verify_request():
woocommerce_settings = frappe.get_doc("Woocommerce Settings")
sig = base64.b64encode(
hmac.new(
woocommerce_settings.secret.encode('utf8'),
frappe.request.data,
hashlib.sha256
woocommerce_settings.secret.encode("utf8"), frappe.request.data, hashlib.sha256
).digest()
)
if frappe.request.data and \
not sig == frappe.get_request_header("X-Wc-Webhook-Signature", "").encode():
frappe.throw(_("Unverified Webhook Data"))
if (
frappe.request.data
and not sig == frappe.get_request_header("X-Wc-Webhook-Signature", "").encode()
):
frappe.throw(_("Unverified Webhook Data"))
frappe.set_user(woocommerce_settings.creation_user)
@frappe.whitelist(allow_guest=True)
def order(*args, **kwargs):
try:
_order(*args, **kwargs)
except Exception:
error_message = frappe.get_traceback()+"\n\n Request Data: \n"+json.loads(frappe.request.data).__str__()
error_message = (
frappe.get_traceback() + "\n\n Request Data: \n" + json.loads(frappe.request.data).__str__()
)
frappe.log_error(error_message, "WooCommerce Error")
raise
def _order(*args, **kwargs):
woocommerce_settings = frappe.get_doc("Woocommerce Settings")
if frappe.flags.woocomm_test_order_data:
@@ -43,7 +47,7 @@ def _order(*args, **kwargs):
try:
order = json.loads(frappe.request.data)
except ValueError:
#woocommerce returns 'webhook_id=value' for the first request which is not JSON
# woocommerce returns 'webhook_id=value' for the first request which is not JSON
order = frappe.request.data
event = frappe.get_request_header("X-Wc-Webhook-Event")
@@ -51,7 +55,7 @@ def _order(*args, **kwargs):
return "success"
if event == "created":
sys_lang = frappe.get_single("System Settings").language or 'en'
sys_lang = frappe.get_single("System Settings").language or "en"
raw_billing_data = order.get("billing")
raw_shipping_data = order.get("shipping")
customer_name = raw_billing_data.get("first_name") + " " + raw_billing_data.get("last_name")
@@ -59,6 +63,7 @@ def _order(*args, **kwargs):
link_items(order.get("line_items"), woocommerce_settings, sys_lang)
create_sales_order(order, woocommerce_settings, customer_name, sys_lang)
def link_customer_and_address(raw_billing_data, raw_shipping_data, customer_name):
customer_woo_com_email = raw_billing_data.get("email")
customer_exists = frappe.get_value("Customer", {"woocommerce_email": customer_woo_com_email})
@@ -77,9 +82,14 @@ def link_customer_and_address(raw_billing_data, raw_shipping_data, customer_name
if customer_exists:
frappe.rename_doc("Customer", old_name, customer_name)
for address_type in ("Billing", "Shipping",):
for address_type in (
"Billing",
"Shipping",
):
try:
address = frappe.get_doc("Address", {"woocommerce_email": customer_woo_com_email, "address_type": address_type})
address = frappe.get_doc(
"Address", {"woocommerce_email": customer_woo_com_email, "address_type": address_type}
)
rename_address(address, customer)
except (
frappe.DoesNotExistError,
@@ -92,6 +102,7 @@ def link_customer_and_address(raw_billing_data, raw_shipping_data, customer_name
create_address(raw_shipping_data, customer, "Shipping")
create_contact(raw_billing_data, customer)
def create_contact(data, customer):
email = data.get("email", None)
phone = data.get("phone", None)
@@ -111,14 +122,12 @@ def create_contact(data, customer):
if email:
contact.add_email(email, is_primary=1)
contact.append("links", {
"link_doctype": "Customer",
"link_name": customer.name
})
contact.append("links", {"link_doctype": "Customer", "link_name": customer.name})
contact.flags.ignore_mandatory = True
contact.save()
def create_address(raw_data, customer, address_type):
address = frappe.new_doc("Address")
@@ -132,14 +141,12 @@ def create_address(raw_data, customer, address_type):
address.pincode = raw_data.get("postcode")
address.phone = raw_data.get("phone")
address.email_id = customer.woocommerce_email
address.append("links", {
"link_doctype": "Customer",
"link_name": customer.name
})
address.append("links", {"link_doctype": "Customer", "link_name": customer.name})
address.flags.ignore_mandatory = True
address.save()
def rename_address(address, customer):
old_address_title = address.name
new_address_title = customer.name + "-" + address.address_type
@@ -148,12 +155,13 @@ def rename_address(address, customer):
frappe.rename_doc("Address", old_address_title, new_address_title)
def link_items(items_list, woocommerce_settings, sys_lang):
for item_data in items_list:
item_woo_com_id = cstr(item_data.get("product_id"))
if not frappe.db.get_value("Item", {"woocommerce_id": item_woo_com_id}, 'name'):
#Create Item
if not frappe.db.get_value("Item", {"woocommerce_id": item_woo_com_id}, "name"):
# Create Item
item = frappe.new_doc("Item")
item.item_code = _("woocommerce - {0}", sys_lang).format(item_woo_com_id)
item.stock_uom = woocommerce_settings.uom or _("Nos", sys_lang)
@@ -164,6 +172,7 @@ def link_items(items_list, woocommerce_settings, sys_lang):
item.flags.ignore_mandatory = True
item.save()
def create_sales_order(order, woocommerce_settings, customer_name, sys_lang):
new_sales_order = frappe.new_doc("Sales Order")
new_sales_order.customer = customer_name
@@ -185,12 +194,12 @@ def create_sales_order(order, woocommerce_settings, customer_name, sys_lang):
frappe.db.commit()
def set_items_in_sales_order(new_sales_order, woocommerce_settings, order, sys_lang):
company_abbr = frappe.db.get_value('Company', woocommerce_settings.company, 'abbr')
company_abbr = frappe.db.get_value("Company", woocommerce_settings.company, "abbr")
default_warehouse = _("Stores - {0}", sys_lang).format(company_abbr)
if not frappe.db.exists("Warehouse", default_warehouse) \
and not woocommerce_settings.warehouse:
if not frappe.db.exists("Warehouse", default_warehouse) and not woocommerce_settings.warehouse:
frappe.throw(_("Please set Warehouse in Woocommerce Settings"))
for item in order.get("line_items"):
@@ -199,28 +208,44 @@ def set_items_in_sales_order(new_sales_order, woocommerce_settings, order, sys_l
ordered_items_tax = item.get("total_tax")
new_sales_order.append("items", {
"item_code": found_item.name,
"item_name": found_item.item_name,
"description": found_item.item_name,
"delivery_date": new_sales_order.delivery_date,
"uom": woocommerce_settings.uom or _("Nos", sys_lang),
"qty": item.get("quantity"),
"rate": item.get("price"),
"warehouse": woocommerce_settings.warehouse or default_warehouse
})
new_sales_order.append(
"items",
{
"item_code": found_item.name,
"item_name": found_item.item_name,
"description": found_item.item_name,
"delivery_date": new_sales_order.delivery_date,
"uom": woocommerce_settings.uom or _("Nos", sys_lang),
"qty": item.get("quantity"),
"rate": item.get("price"),
"warehouse": woocommerce_settings.warehouse or default_warehouse,
},
)
add_tax_details(new_sales_order, ordered_items_tax, "Ordered Item tax", woocommerce_settings.tax_account)
add_tax_details(
new_sales_order, ordered_items_tax, "Ordered Item tax", woocommerce_settings.tax_account
)
# shipping_details = order.get("shipping_lines") # used for detailed order
add_tax_details(new_sales_order, order.get("shipping_tax"), "Shipping Tax", woocommerce_settings.f_n_f_account)
add_tax_details(new_sales_order, order.get("shipping_total"), "Shipping Total", woocommerce_settings.f_n_f_account)
add_tax_details(
new_sales_order, order.get("shipping_tax"), "Shipping Tax", woocommerce_settings.f_n_f_account
)
add_tax_details(
new_sales_order,
order.get("shipping_total"),
"Shipping Total",
woocommerce_settings.f_n_f_account,
)
def add_tax_details(sales_order, price, desc, tax_account_head):
sales_order.append("taxes", {
"charge_type":"Actual",
"account_head": tax_account_head,
"tax_amount": price,
"description": desc
})
sales_order.append(
"taxes",
{
"charge_type": "Actual",
"account_head": tax_account_head,
"tax_amount": price,
"description": desc,
},
)

View File

@@ -3,10 +3,10 @@ import frappe
def pre_process(issue):
project = frappe.db.get_value('Project', filters={'project_name': issue.milestone})
project = frappe.db.get_value("Project", filters={"project_name": issue.milestone})
return {
'title': issue.title,
'body': frappe.utils.md_to_html(issue.body or ''),
'state': issue.state.title(),
'project': project or ''
"title": issue.title,
"body": frappe.utils.md_to_html(issue.body or ""),
"state": issue.state.title(),
"project": project or "",
}

View File

@@ -1,6 +1,6 @@
def pre_process(milestone):
return {
'title': milestone.title,
'description': milestone.description,
'state': milestone.state.title()
"title": milestone.title,
"description": milestone.description,
"state": milestone.state.title(),
}

View File

@@ -14,7 +14,9 @@ class ExotelSettings(Document):
def verify_credentials(self):
if self.enabled:
response = requests.get('https://api.exotel.com/v1/Accounts/{sid}'
.format(sid = self.account_sid), auth=(self.api_key, self.api_token))
response = requests.get(
"https://api.exotel.com/v1/Accounts/{sid}".format(sid=self.account_sid),
auth=(self.api_key, self.api_token),
)
if response.status_code != 200:
frappe.throw(_("Invalid credentials"))

View File

@@ -23,12 +23,15 @@ def webhooks():
set_status(event)
return 200
def set_status(event):
resource_type = event.get("resource_type", {})
if resource_type == "mandates":
set_mandate_status(event)
def set_mandate_status(event):
mandates = []
if isinstance(event["links"], (list,)):
@@ -37,7 +40,12 @@ def set_mandate_status(event):
else:
mandates.append(event["links"]["mandate"])
if event["action"] == "pending_customer_approval" or event["action"] == "pending_submission" or event["action"] == "submitted" or event["action"] == "active":
if (
event["action"] == "pending_customer_approval"
or event["action"] == "pending_submission"
or event["action"] == "submitted"
or event["action"] == "active"
):
disabled = 0
else:
disabled = 1
@@ -45,6 +53,7 @@ def set_mandate_status(event):
for mandate in mandates:
frappe.db.set_value("GoCardless Mandate", mandate, "disabled", disabled)
def authenticate_signature(r):
"""Returns True if the received signature matches the generated signature"""
received_signature = frappe.get_request_header("Webhook-Signature")
@@ -59,13 +68,22 @@ def authenticate_signature(r):
return False
def get_webhook_keys():
def _get_webhook_keys():
webhook_keys = [d.webhooks_secret for d in frappe.get_all("GoCardless Settings", fields=["webhooks_secret"],) if d.webhooks_secret]
webhook_keys = [
d.webhooks_secret
for d in frappe.get_all(
"GoCardless Settings",
fields=["webhooks_secret"],
)
if d.webhooks_secret
]
return webhook_keys
return frappe.cache().get_value("gocardless_webhooks_secret", _get_webhook_keys)
def clear_cache():
frappe.cache().delete_value("gocardless_webhooks_secret")

View File

@@ -13,7 +13,7 @@ from frappe.utils import call_hook_method, cint, flt, get_url
class GoCardlessSettings(Document):
supported_currencies = ["EUR", "DKK", "GBP", "SEK"]
supported_currencies = ["EUR", "DKK", "GBP", "SEK", "AUD", "NZD", "CAD", "USD"]
def validate(self):
self.initialize_client()
@@ -22,32 +22,35 @@ class GoCardlessSettings(Document):
self.environment = self.get_environment()
try:
self.client = gocardless_pro.Client(
access_token=self.access_token,
environment=self.environment
)
access_token=self.access_token, environment=self.environment
)
return self.client
except Exception as e:
frappe.throw(e)
def on_update(self):
create_payment_gateway('GoCardless-' + self.gateway_name, settings='GoCardLess Settings', controller=self.gateway_name)
call_hook_method('payment_gateway_enabled', gateway='GoCardless-' + self.gateway_name)
create_payment_gateway(
"GoCardless-" + self.gateway_name, settings="GoCardLess Settings", controller=self.gateway_name
)
call_hook_method("payment_gateway_enabled", gateway="GoCardless-" + self.gateway_name)
def on_payment_request_submission(self, data):
if data.reference_doctype != "Fees":
customer_data = frappe.db.get_value(data.reference_doctype, data.reference_name, ["company", "customer_name"], as_dict=1)
customer_data = frappe.db.get_value(
data.reference_doctype, data.reference_name, ["company", "customer_name"], as_dict=1
)
data = {
"amount": flt(data.grand_total, data.precision("grand_total")),
"title": customer_data.company.encode("utf-8"),
"description": data.subject.encode("utf-8"),
"reference_doctype": data.doctype,
"reference_docname": data.name,
"payer_email": data.email_to or frappe.session.user,
"payer_name": customer_data.customer_name,
"order_id": data.name,
"currency": data.currency
}
"amount": flt(data.grand_total, data.precision("grand_total")),
"title": customer_data.company.encode("utf-8"),
"description": data.subject.encode("utf-8"),
"reference_doctype": data.doctype,
"reference_docname": data.name,
"payer_email": data.email_to or frappe.session.user,
"payer_name": customer_data.customer_name,
"order_id": data.name,
"currency": data.currency,
}
valid_mandate = self.check_mandate_validity(data)
if valid_mandate is not None:
@@ -60,12 +63,19 @@ class GoCardlessSettings(Document):
def check_mandate_validity(self, data):
if frappe.db.exists("GoCardless Mandate", dict(customer=data.get('payer_name'), disabled=0)):
registered_mandate = frappe.db.get_value("GoCardless Mandate", dict(customer=data.get('payer_name'), disabled=0), 'mandate')
if frappe.db.exists("GoCardless Mandate", dict(customer=data.get("payer_name"), disabled=0)):
registered_mandate = frappe.db.get_value(
"GoCardless Mandate", dict(customer=data.get("payer_name"), disabled=0), "mandate"
)
self.initialize_client()
mandate = self.client.mandates.get(registered_mandate)
if mandate.status=="pending_customer_approval" or mandate.status=="pending_submission" or mandate.status=="submitted" or mandate.status=="active":
if (
mandate.status == "pending_customer_approval"
or mandate.status == "pending_submission"
or mandate.status == "submitted"
or mandate.status == "active"
):
return {"mandate": registered_mandate}
else:
return None
@@ -74,13 +84,17 @@ class GoCardlessSettings(Document):
def get_environment(self):
if self.use_sandbox:
return 'sandbox'
return "sandbox"
else:
return 'live'
return "live"
def validate_transaction_currency(self, currency):
if currency not in self.supported_currencies:
frappe.throw(_("Please select another payment method. Stripe does not support transactions in currency '{0}'").format(currency))
frappe.throw(
_(
"Please select another payment method. Go Cardless does not support transactions in currency '{0}'"
).format(currency)
)
def get_payment_url(self, **kwargs):
return get_url("./integrations/gocardless_checkout?{0}".format(urlencode(kwargs)))
@@ -94,63 +108,85 @@ class GoCardlessSettings(Document):
except Exception:
frappe.log_error(frappe.get_traceback())
return{
"redirect_to": frappe.redirect_to_message(_('Server Error'), _("There seems to be an issue with the server's GoCardless configuration. Don't worry, in case of failure, the amount will get refunded to your account.")),
"status": 401
return {
"redirect_to": frappe.redirect_to_message(
_("Server Error"),
_(
"There seems to be an issue with the server's GoCardless configuration. Don't worry, in case of failure, the amount will get refunded to your account."
),
),
"status": 401,
}
def create_charge_on_gocardless(self):
redirect_to = self.data.get('redirect_to') or None
redirect_message = self.data.get('redirect_message') or None
redirect_to = self.data.get("redirect_to") or None
redirect_message = self.data.get("redirect_message") or None
reference_doc = frappe.get_doc(self.data.get('reference_doctype'), self.data.get('reference_docname'))
reference_doc = frappe.get_doc(
self.data.get("reference_doctype"), self.data.get("reference_docname")
)
self.initialize_client()
try:
payment = self.client.payments.create(
params={
"amount" : cint(reference_doc.grand_total * 100),
"currency" : reference_doc.currency,
"links" : {
"mandate": self.data.get('mandate')
},
"amount": cint(reference_doc.grand_total * 100),
"currency": reference_doc.currency,
"links": {"mandate": self.data.get("mandate")},
"metadata": {
"reference_doctype": reference_doc.doctype,
"reference_document": reference_doc.name
}
}, headers={
'Idempotency-Key' : self.data.get('reference_docname'),
})
"reference_doctype": reference_doc.doctype,
"reference_document": reference_doc.name,
},
},
headers={
"Idempotency-Key": self.data.get("reference_docname"),
},
)
if payment.status=="pending_submission" or payment.status=="pending_customer_approval" or payment.status=="submitted":
self.integration_request.db_set('status', 'Authorized', update_modified=False)
if (
payment.status == "pending_submission"
or payment.status == "pending_customer_approval"
or payment.status == "submitted"
):
self.integration_request.db_set("status", "Authorized", update_modified=False)
self.flags.status_changed_to = "Completed"
self.integration_request.db_set('output', payment.status, update_modified=False)
self.integration_request.db_set("output", payment.status, update_modified=False)
elif payment.status=="confirmed" or payment.status=="paid_out":
self.integration_request.db_set('status', 'Completed', update_modified=False)
elif payment.status == "confirmed" or payment.status == "paid_out":
self.integration_request.db_set("status", "Completed", update_modified=False)
self.flags.status_changed_to = "Completed"
self.integration_request.db_set('output', payment.status, update_modified=False)
self.integration_request.db_set("output", payment.status, update_modified=False)
elif payment.status=="cancelled" or payment.status=="customer_approval_denied" or payment.status=="charged_back":
self.integration_request.db_set('status', 'Cancelled', update_modified=False)
frappe.log_error(_("Payment Cancelled. Please check your GoCardless Account for more details"), "GoCardless Payment Error")
self.integration_request.db_set('error', payment.status, update_modified=False)
elif (
payment.status == "cancelled"
or payment.status == "customer_approval_denied"
or payment.status == "charged_back"
):
self.integration_request.db_set("status", "Cancelled", update_modified=False)
frappe.log_error(
_("Payment Cancelled. Please check your GoCardless Account for more details"),
"GoCardless Payment Error",
)
self.integration_request.db_set("error", payment.status, update_modified=False)
else:
self.integration_request.db_set('status', 'Failed', update_modified=False)
frappe.log_error(_("Payment Failed. Please check your GoCardless Account for more details"), "GoCardless Payment Error")
self.integration_request.db_set('error', payment.status, update_modified=False)
self.integration_request.db_set("status", "Failed", update_modified=False)
frappe.log_error(
_("Payment Failed. Please check your GoCardless Account for more details"),
"GoCardless Payment Error",
)
self.integration_request.db_set("error", payment.status, update_modified=False)
except Exception as e:
frappe.log_error(e, "GoCardless Payment Error")
if self.flags.status_changed_to == "Completed":
status = 'Completed'
if 'reference_doctype' in self.data and 'reference_docname' in self.data:
status = "Completed"
if "reference_doctype" in self.data and "reference_docname" in self.data:
custom_redirect_to = None
try:
custom_redirect_to = frappe.get_doc(self.data.get('reference_doctype'),
self.data.get('reference_docname')).run_method("on_payment_authorized", self.flags.status_changed_to)
custom_redirect_to = frappe.get_doc(
self.data.get("reference_doctype"), self.data.get("reference_docname")
).run_method("on_payment_authorized", self.flags.status_changed_to)
except Exception:
frappe.log_error(frappe.get_traceback())
@@ -159,24 +195,25 @@ class GoCardlessSettings(Document):
redirect_url = redirect_to
else:
status = 'Error'
redirect_url = 'payment-failed'
status = "Error"
redirect_url = "payment-failed"
if redirect_message:
redirect_url += '&' + urlencode({'redirect_message': redirect_message})
redirect_url += "&" + urlencode({"redirect_message": redirect_message})
redirect_url = get_url(redirect_url)
return {
"redirect_to": redirect_url,
"status": status
}
return {"redirect_to": redirect_url, "status": status}
def get_gateway_controller(doc):
payment_request = frappe.get_doc("Payment Request", doc)
gateway_controller = frappe.db.get_value("Payment Gateway", payment_request.payment_gateway, "gateway_controller")
gateway_controller = frappe.db.get_value(
"Payment Gateway", payment_request.payment_gateway, "gateway_controller"
)
return gateway_controller
def gocardless_initialization(doc):
gateway_controller = get_gateway_controller(doc)
settings = frappe.get_doc("GoCardless Settings", gateway_controller)

View File

@@ -5,9 +5,15 @@ import requests
from requests.auth import HTTPBasicAuth
class MpesaConnector():
def __init__(self, env="sandbox", app_key=None, app_secret=None, sandbox_url="https://sandbox.safaricom.co.ke",
live_url="https://api.safaricom.co.ke"):
class MpesaConnector:
def __init__(
self,
env="sandbox",
app_key=None,
app_secret=None,
sandbox_url="https://sandbox.safaricom.co.ke",
live_url="https://api.safaricom.co.ke",
):
"""Setup configuration for Mpesa connector and generate new access token."""
self.env = env
self.app_key = app_key
@@ -23,36 +29,41 @@ class MpesaConnector():
This method is used to fetch the access token required by Mpesa.
Returns:
access_token (str): This token is to be used with the Bearer header for further API calls to Mpesa.
access_token (str): This token is to be used with the Bearer header for further API calls to Mpesa.
"""
authenticate_uri = "/oauth/v1/generate?grant_type=client_credentials"
authenticate_url = "{0}{1}".format(self.base_url, authenticate_uri)
r = requests.get(
authenticate_url,
auth=HTTPBasicAuth(self.app_key, self.app_secret)
)
self.authentication_token = r.json()['access_token']
return r.json()['access_token']
r = requests.get(authenticate_url, auth=HTTPBasicAuth(self.app_key, self.app_secret))
self.authentication_token = r.json()["access_token"]
return r.json()["access_token"]
def get_balance(self, initiator=None, security_credential=None, party_a=None, identifier_type=None,
remarks=None, queue_timeout_url=None,result_url=None):
def get_balance(
self,
initiator=None,
security_credential=None,
party_a=None,
identifier_type=None,
remarks=None,
queue_timeout_url=None,
result_url=None,
):
"""
This method uses Mpesa's Account Balance API to to enquire the balance on a M-Pesa BuyGoods (Till Number).
Args:
initiator (str): Username used to authenticate the transaction.
security_credential (str): Generate from developer portal.
command_id (str): AccountBalance.
party_a (int): Till number being queried.
identifier_type (int): Type of organization receiving the transaction. (MSISDN/Till Number/Organization short code)
remarks (str): Comments that are sent along with the transaction(maximum 100 characters).
queue_timeout_url (str): The url that handles information of timed out transactions.
result_url (str): The url that receives results from M-Pesa api call.
initiator (str): Username used to authenticate the transaction.
security_credential (str): Generate from developer portal.
command_id (str): AccountBalance.
party_a (int): Till number being queried.
identifier_type (int): Type of organization receiving the transaction. (MSISDN/Till Number/Organization short code)
remarks (str): Comments that are sent along with the transaction(maximum 100 characters).
queue_timeout_url (str): The url that handles information of timed out transactions.
result_url (str): The url that receives results from M-Pesa api call.
Returns:
OriginatorConverstionID (str): The unique request ID for tracking a transaction.
ConversationID (str): The unique request ID returned by mpesa for each request made
ResponseDescription (str): Response Description message
OriginatorConverstionID (str): The unique request ID for tracking a transaction.
ConversationID (str): The unique request ID returned by mpesa for each request made
ResponseDescription (str): Response Description message
"""
payload = {
@@ -63,43 +74,56 @@ class MpesaConnector():
"IdentifierType": identifier_type,
"Remarks": remarks,
"QueueTimeOutURL": queue_timeout_url,
"ResultURL": result_url
"ResultURL": result_url,
}
headers = {
"Authorization": "Bearer {0}".format(self.authentication_token),
"Content-Type": "application/json",
}
headers = {'Authorization': 'Bearer {0}'.format(self.authentication_token), 'Content-Type': "application/json"}
saf_url = "{0}{1}".format(self.base_url, "/mpesa/accountbalance/v1/query")
r = requests.post(saf_url, headers=headers, json=payload)
return r.json()
def stk_push(self, business_shortcode=None, passcode=None, amount=None, callback_url=None, reference_code=None,
phone_number=None, description=None):
def stk_push(
self,
business_shortcode=None,
passcode=None,
amount=None,
callback_url=None,
reference_code=None,
phone_number=None,
description=None,
):
"""
This method uses Mpesa's Express API to initiate online payment on behalf of a customer.
Args:
business_shortcode (int): The short code of the organization.
passcode (str): Get from developer portal
amount (int): The amount being transacted
callback_url (str): A CallBack URL is a valid secure URL that is used to receive notifications from M-Pesa API.
reference_code(str): Account Reference: This is an Alpha-Numeric parameter that is defined by your system as an Identifier of the transaction for CustomerPayBillOnline transaction type.
phone_number(int): The Mobile Number to receive the STK Pin Prompt.
description(str): This is any additional information/comment that can be sent along with the request from your system. MAX 13 characters
business_shortcode (int): The short code of the organization.
passcode (str): Get from developer portal
amount (int): The amount being transacted
callback_url (str): A CallBack URL is a valid secure URL that is used to receive notifications from M-Pesa API.
reference_code(str): Account Reference: This is an Alpha-Numeric parameter that is defined by your system as an Identifier of the transaction for CustomerPayBillOnline transaction type.
phone_number(int): The Mobile Number to receive the STK Pin Prompt.
description(str): This is any additional information/comment that can be sent along with the request from your system. MAX 13 characters
Success Response:
CustomerMessage(str): Messages that customers can understand.
CheckoutRequestID(str): This is a global unique identifier of the processed checkout transaction request.
ResponseDescription(str): Describes Success or failure
MerchantRequestID(str): This is a global unique Identifier for any submitted payment request.
ResponseCode(int): 0 means success all others are error codes. e.g.404.001.03
CustomerMessage(str): Messages that customers can understand.
CheckoutRequestID(str): This is a global unique identifier of the processed checkout transaction request.
ResponseDescription(str): Describes Success or failure
MerchantRequestID(str): This is a global unique Identifier for any submitted payment request.
ResponseCode(int): 0 means success all others are error codes. e.g.404.001.03
Error Reponse:
requestId(str): This is a unique requestID for the payment request
errorCode(str): This is a predefined code that indicates the reason for request failure.
errorMessage(str): This is a predefined code that indicates the reason for request failure.
requestId(str): This is a unique requestID for the payment request
errorCode(str): This is a predefined code that indicates the reason for request failure.
errorMessage(str): This is a predefined code that indicates the reason for request failure.
"""
time = str(datetime.datetime.now()).split(".")[0].replace("-", "").replace(" ", "").replace(":", "")
time = (
str(datetime.datetime.now()).split(".")[0].replace("-", "").replace(" ", "").replace(":", "")
)
password = "{0}{1}{2}".format(str(business_shortcode), str(passcode), time)
encoded = base64.b64encode(bytes(password, encoding='utf8'))
encoded = base64.b64encode(bytes(password, encoding="utf8"))
payload = {
"BusinessShortCode": business_shortcode,
"Password": encoded.decode("utf-8"),
@@ -111,9 +135,14 @@ class MpesaConnector():
"CallBackURL": callback_url,
"AccountReference": reference_code,
"TransactionDesc": description,
"TransactionType": "CustomerPayBillOnline" if self.env == "sandbox" else "CustomerBuyGoodsOnline"
"TransactionType": "CustomerPayBillOnline"
if self.env == "sandbox"
else "CustomerBuyGoodsOnline",
}
headers = {
"Authorization": "Bearer {0}".format(self.authentication_token),
"Content-Type": "application/json",
}
headers = {'Authorization': 'Bearer {0}'.format(self.authentication_token), 'Content-Type': "application/json"}
saf_url = "{0}{1}".format(self.base_url, "/mpesa/stkpush/v1/processrequest")
r = requests.post(saf_url, headers=headers, json=payload)

View File

@@ -11,21 +11,22 @@ def create_custom_pos_fields():
"label": "Request for Payment",
"fieldtype": "Button",
"hidden": 1,
"insert_after": "contact_email"
"insert_after": "contact_email",
},
{
"fieldname": "mpesa_receipt_number",
"label": "Mpesa Receipt Number",
"fieldtype": "Data",
"read_only": 1,
"insert_after": "company"
}
"insert_after": "company",
},
]
}
if not frappe.get_meta("POS Invoice").has_field("request_for_payment"):
create_custom_fields(pos_field)
record_dict = [{
record_dict = [
{
"doctype": "POS Field",
"fieldname": "contact_mobile",
"label": "Mobile No",
@@ -33,7 +34,7 @@ def create_custom_pos_fields():
"options": "Phone",
"parenttype": "POS Settings",
"parent": "POS Settings",
"parentfield": "invoice_fields"
"parentfield": "invoice_fields",
},
{
"doctype": "POS Field",
@@ -42,11 +43,12 @@ def create_custom_pos_fields():
"fieldtype": "Button",
"parenttype": "POS Settings",
"parent": "POS Settings",
"parentfield": "invoice_fields"
}
"parentfield": "invoice_fields",
},
]
create_pos_settings(record_dict)
def create_pos_settings(record_dict):
for record in record_dict:
if frappe.db.exists("POS Field", {"fieldname": record.get("fieldname")}):

View File

@@ -2,7 +2,6 @@
# For license information, please see license.txt
from json import dumps, loads
import frappe
@@ -23,16 +22,26 @@ class MpesaSettings(Document):
def validate_transaction_currency(self, currency):
if currency not in self.supported_currencies:
frappe.throw(_("Please select another payment method. Mpesa does not support transactions in currency '{0}'").format(currency))
frappe.throw(
_(
"Please select another payment method. Mpesa does not support transactions in currency '{0}'"
).format(currency)
)
def on_update(self):
create_custom_pos_fields()
create_payment_gateway('Mpesa-' + self.payment_gateway_name, settings='Mpesa Settings', controller=self.payment_gateway_name)
call_hook_method('payment_gateway_enabled', gateway='Mpesa-' + self.payment_gateway_name, payment_channel="Phone")
create_payment_gateway(
"Mpesa-" + self.payment_gateway_name,
settings="Mpesa Settings",
controller=self.payment_gateway_name,
)
call_hook_method(
"payment_gateway_enabled", gateway="Mpesa-" + self.payment_gateway_name, payment_channel="Phone"
)
# required to fetch the bank account details from the payment gateway account
frappe.db.commit()
create_mode_of_payment('Mpesa-' + self.payment_gateway_name, payment_type="Phone")
create_mode_of_payment("Mpesa-" + self.payment_gateway_name, payment_type="Phone")
def request_for_payment(self, **kwargs):
args = frappe._dict(kwargs)
@@ -44,6 +53,7 @@ class MpesaSettings(Document):
from erpnext.erpnext_integrations.doctype.mpesa_settings.test_mpesa_settings import (
get_payment_request_response_payload,
)
response = frappe._dict(get_payment_request_response_payload(amount))
else:
response = frappe._dict(generate_stk_push(**args))
@@ -55,11 +65,15 @@ class MpesaSettings(Document):
if request_amount > self.transaction_limit:
# make multiple requests
request_amounts = []
requests_to_be_made = frappe.utils.ceil(request_amount / self.transaction_limit) # 480/150 = ceil(3.2) = 4
requests_to_be_made = frappe.utils.ceil(
request_amount / self.transaction_limit
) # 480/150 = ceil(3.2) = 4
for i in range(requests_to_be_made):
amount = self.transaction_limit
if i == requests_to_be_made - 1:
amount = request_amount - (self.transaction_limit * i) # for 4th request, 480 - (150 * 3) = 30
amount = request_amount - (
self.transaction_limit * i
) # for 4th request, 480 - (150 * 3) = 30
request_amounts.append(amount)
else:
request_amounts = [request_amount]
@@ -69,15 +83,14 @@ class MpesaSettings(Document):
@frappe.whitelist()
def get_account_balance_info(self):
payload = dict(
reference_doctype="Mpesa Settings",
reference_docname=self.name,
doc_details=vars(self)
reference_doctype="Mpesa Settings", reference_docname=self.name, doc_details=vars(self)
)
if frappe.flags.in_test:
from erpnext.erpnext_integrations.doctype.mpesa_settings.test_mpesa_settings import (
get_test_account_balance_response,
)
response = frappe._dict(get_test_account_balance_response())
else:
response = frappe._dict(get_account_balance(payload))
@@ -95,46 +108,62 @@ class MpesaSettings(Document):
req_name = getattr(response, global_id)
error = None
if not frappe.db.exists('Integration Request', req_name):
if not frappe.db.exists("Integration Request", req_name):
create_request_log(request_dict, "Host", "Mpesa", req_name, error)
if error:
frappe.throw(_(getattr(response, "errorMessage")), title=_("Transaction Error"))
def generate_stk_push(**kwargs):
"""Generate stk push by making a API call to the stk push API."""
args = frappe._dict(kwargs)
try:
callback_url = get_request_site_address(True) + "/api/method/erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings.verify_transaction"
callback_url = (
get_request_site_address(True)
+ "/api/method/erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings.verify_transaction"
)
mpesa_settings = frappe.get_doc("Mpesa Settings", args.payment_gateway[6:])
env = "production" if not mpesa_settings.sandbox else "sandbox"
# for sandbox, business shortcode is same as till number
business_shortcode = mpesa_settings.business_shortcode if env == "production" else mpesa_settings.till_number
business_shortcode = (
mpesa_settings.business_shortcode if env == "production" else mpesa_settings.till_number
)
connector = MpesaConnector(env=env,
connector = MpesaConnector(
env=env,
app_key=mpesa_settings.consumer_key,
app_secret=mpesa_settings.get_password("consumer_secret"))
app_secret=mpesa_settings.get_password("consumer_secret"),
)
mobile_number = sanitize_mobile_number(args.sender)
response = connector.stk_push(
business_shortcode=business_shortcode, amount=args.request_amount,
business_shortcode=business_shortcode,
amount=args.request_amount,
passcode=mpesa_settings.get_password("online_passkey"),
callback_url=callback_url, reference_code=mpesa_settings.till_number,
phone_number=mobile_number, description="POS Payment"
callback_url=callback_url,
reference_code=mpesa_settings.till_number,
phone_number=mobile_number,
description="POS Payment",
)
return response
except Exception:
frappe.log_error(title=_("Mpesa Express Transaction Error"))
frappe.throw(_("Issue detected with Mpesa configuration, check the error logs for more details"), title=_("Mpesa Express Error"))
frappe.throw(
_("Issue detected with Mpesa configuration, check the error logs for more details"),
title=_("Mpesa Express Error"),
)
def sanitize_mobile_number(number):
"""Add country code and strip leading zeroes from the phone number."""
return "254" + str(number).lstrip("0")
@frappe.whitelist(allow_guest=True)
def verify_transaction(**kwargs):
"""Verify the transaction result received via callback from stk."""
@@ -146,28 +175,28 @@ def verify_transaction(**kwargs):
integration_request = frappe.get_doc("Integration Request", checkout_id)
transaction_data = frappe._dict(loads(integration_request.data))
total_paid = 0 # for multiple integration request made against a pos invoice
success = False # for reporting successfull callback to point of sale ui
total_paid = 0 # for multiple integration request made against a pos invoice
success = False # for reporting successfull callback to point of sale ui
if transaction_response['ResultCode'] == 0:
if transaction_response["ResultCode"] == 0:
if integration_request.reference_doctype and integration_request.reference_docname:
try:
item_response = transaction_response["CallbackMetadata"]["Item"]
amount = fetch_param_value(item_response, "Amount", "Name")
mpesa_receipt = fetch_param_value(item_response, "MpesaReceiptNumber", "Name")
pr = frappe.get_doc(integration_request.reference_doctype, integration_request.reference_docname)
pr = frappe.get_doc(
integration_request.reference_doctype, integration_request.reference_docname
)
mpesa_receipts, completed_payments = get_completed_integration_requests_info(
integration_request.reference_doctype,
integration_request.reference_docname,
checkout_id
integration_request.reference_doctype, integration_request.reference_docname, checkout_id
)
total_paid = amount + sum(completed_payments)
mpesa_receipts = ', '.join(mpesa_receipts + [mpesa_receipt])
mpesa_receipts = ", ".join(mpesa_receipts + [mpesa_receipt])
if total_paid >= pr.grand_total:
pr.run_method("on_payment_authorized", 'Completed')
pr.run_method("on_payment_authorized", "Completed")
success = True
frappe.db.set_value("POS Invoice", pr.reference_name, "mpesa_receipt_number", mpesa_receipts)
@@ -180,24 +209,31 @@ def verify_transaction(**kwargs):
integration_request.handle_failure(transaction_response)
frappe.publish_realtime(
event='process_phone_payment',
event="process_phone_payment",
doctype="POS Invoice",
docname=transaction_data.payment_reference,
user=integration_request.owner,
message={
'amount': total_paid,
'success': success,
'failure_message': transaction_response["ResultDesc"] if transaction_response['ResultCode'] != 0 else ''
"amount": total_paid,
"success": success,
"failure_message": transaction_response["ResultDesc"]
if transaction_response["ResultCode"] != 0
else "",
},
)
def get_completed_integration_requests_info(reference_doctype, reference_docname, checkout_id):
output_of_other_completed_requests = frappe.get_all("Integration Request", filters={
'name': ['!=', checkout_id],
'reference_doctype': reference_doctype,
'reference_docname': reference_docname,
'status': 'Completed'
}, pluck="output")
output_of_other_completed_requests = frappe.get_all(
"Integration Request",
filters={
"name": ["!=", checkout_id],
"reference_doctype": reference_doctype,
"reference_docname": reference_docname,
"status": "Completed",
},
pluck="output",
)
mpesa_receipts, completed_payments = [], []
@@ -211,23 +247,38 @@ def get_completed_integration_requests_info(reference_doctype, reference_docname
return mpesa_receipts, completed_payments
def get_account_balance(request_payload):
"""Call account balance API to send the request to the Mpesa Servers."""
try:
mpesa_settings = frappe.get_doc("Mpesa Settings", request_payload.get("reference_docname"))
env = "production" if not mpesa_settings.sandbox else "sandbox"
connector = MpesaConnector(env=env,
connector = MpesaConnector(
env=env,
app_key=mpesa_settings.consumer_key,
app_secret=mpesa_settings.get_password("consumer_secret"))
app_secret=mpesa_settings.get_password("consumer_secret"),
)
callback_url = get_request_site_address(True) + "/api/method/erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings.process_balance_info"
callback_url = (
get_request_site_address(True)
+ "/api/method/erpnext.erpnext_integrations.doctype.mpesa_settings.mpesa_settings.process_balance_info"
)
response = connector.get_balance(mpesa_settings.initiator_name, mpesa_settings.security_credential, mpesa_settings.till_number, 4, mpesa_settings.name, callback_url, callback_url)
response = connector.get_balance(
mpesa_settings.initiator_name,
mpesa_settings.security_credential,
mpesa_settings.till_number,
4,
mpesa_settings.name,
callback_url,
callback_url,
)
return response
except Exception:
frappe.log_error(title=_("Account Balance Processing Error"))
frappe.throw(_("Please check your configuration and try again"), title=_("Error"))
@frappe.whitelist(allow_guest=True)
def process_balance_info(**kwargs):
"""Process and store account balance information received via callback from the account balance API call."""
@@ -255,35 +306,43 @@ def process_balance_info(**kwargs):
ref_doc.db_set("account_balance", balance_info)
request.handle_success(account_balance_response)
frappe.publish_realtime("refresh_mpesa_dashboard", doctype="Mpesa Settings",
docname=transaction_data.reference_docname, user=transaction_data.owner)
frappe.publish_realtime(
"refresh_mpesa_dashboard",
doctype="Mpesa Settings",
docname=transaction_data.reference_docname,
user=transaction_data.owner,
)
except Exception:
request.handle_failure(account_balance_response)
frappe.log_error(title=_("Mpesa Account Balance Processing Error"), message=account_balance_response)
frappe.log_error(
title=_("Mpesa Account Balance Processing Error"), message=account_balance_response
)
else:
request.handle_failure(account_balance_response)
def format_string_to_json(balance_info):
"""
Format string to json.
e.g: '''Working Account|KES|481000.00|481000.00|0.00|0.00'''
=> {'Working Account': {'current_balance': '481000.00',
'available_balance': '481000.00',
'reserved_balance': '0.00',
'uncleared_balance': '0.00'}}
'available_balance': '481000.00',
'reserved_balance': '0.00',
'uncleared_balance': '0.00'}}
"""
balance_dict = frappe._dict()
for account_info in balance_info.split("&"):
account_info = account_info.split('|')
account_info = account_info.split("|")
balance_dict[account_info[0]] = dict(
current_balance=fmt_money(account_info[2], currency="KES"),
available_balance=fmt_money(account_info[3], currency="KES"),
reserved_balance=fmt_money(account_info[4], currency="KES"),
uncleared_balance=fmt_money(account_info[5], currency="KES")
uncleared_balance=fmt_money(account_info[5], currency="KES"),
)
return dumps(balance_dict)
def fetch_param_value(response, key, key_field):
"""Fetch the specified key from list of dictionary. Key is identified via the key field."""
for param in response:

View File

@@ -22,12 +22,12 @@ class TestMpesaSettings(unittest.TestCase):
create_mpesa_settings(payment_gateway_name="Payment")
def tearDown(self):
frappe.db.sql('delete from `tabMpesa Settings`')
frappe.db.sql("delete from `tabMpesa Settings`")
frappe.db.sql('delete from `tabIntegration Request` where integration_request_service = "Mpesa"')
def test_creation_of_payment_gateway(self):
mode_of_payment = create_mode_of_payment('Mpesa-_Test', payment_type="Phone")
self.assertTrue(frappe.db.exists("Payment Gateway Account", {'payment_gateway': "Mpesa-_Test"}))
mode_of_payment = create_mode_of_payment("Mpesa-_Test", payment_type="Phone")
self.assertTrue(frappe.db.exists("Payment Gateway Account", {"payment_gateway": "Mpesa-_Test"}))
self.assertTrue(mode_of_payment.name)
self.assertEqual(mode_of_payment.type, "Phone")
@@ -45,24 +45,33 @@ class TestMpesaSettings(unittest.TestCase):
# test formatting of account balance received as string to json with appropriate currency symbol
mpesa_doc.reload()
self.assertEqual(mpesa_doc.account_balance, dumps({
"Working Account": {
"current_balance": "Sh 481,000.00",
"available_balance": "Sh 481,000.00",
"reserved_balance": "Sh 0.00",
"uncleared_balance": "Sh 0.00"
}
}))
self.assertEqual(
mpesa_doc.account_balance,
dumps(
{
"Working Account": {
"current_balance": "Sh 481,000.00",
"available_balance": "Sh 481,000.00",
"reserved_balance": "Sh 0.00",
"uncleared_balance": "Sh 0.00",
}
}
),
)
integration_request.delete()
def test_processing_of_callback_payload(self):
mpesa_account = frappe.db.get_value("Payment Gateway Account", {"payment_gateway": 'Mpesa-Payment'}, "payment_account")
mpesa_account = frappe.db.get_value(
"Payment Gateway Account", {"payment_gateway": "Mpesa-Payment"}, "payment_account"
)
frappe.db.set_value("Account", mpesa_account, "account_currency", "KES")
frappe.db.set_value("Customer", "_Test Customer", "default_currency", "KES")
pos_invoice = create_pos_invoice(do_not_submit=1)
pos_invoice.append("payments", {'mode_of_payment': 'Mpesa-Payment', 'account': mpesa_account, 'amount': 500})
pos_invoice.append(
"payments", {"mode_of_payment": "Mpesa-Payment", "account": mpesa_account, "amount": 500}
)
pos_invoice.contact_mobile = "093456543894"
pos_invoice.currency = "KES"
pos_invoice.save()
@@ -72,12 +81,18 @@ class TestMpesaSettings(unittest.TestCase):
self.assertEqual(pr.payment_gateway, "Mpesa-Payment")
# submitting payment request creates integration requests with random id
integration_req_ids = frappe.get_all("Integration Request", filters={
'reference_doctype': pr.doctype,
'reference_docname': pr.name,
}, pluck="name")
integration_req_ids = frappe.get_all(
"Integration Request",
filters={
"reference_doctype": pr.doctype,
"reference_docname": pr.name,
},
pluck="name",
)
callback_response = get_payment_callback_payload(Amount=500, CheckoutRequestID=integration_req_ids[0])
callback_response = get_payment_callback_payload(
Amount=500, CheckoutRequestID=integration_req_ids[0]
)
verify_transaction(**callback_response)
# test creation of integration request
integration_request = frappe.get_doc("Integration Request", integration_req_ids[0])
@@ -99,13 +114,17 @@ class TestMpesaSettings(unittest.TestCase):
pos_invoice.delete()
def test_processing_of_multiple_callback_payload(self):
mpesa_account = frappe.db.get_value("Payment Gateway Account", {"payment_gateway": 'Mpesa-Payment'}, "payment_account")
mpesa_account = frappe.db.get_value(
"Payment Gateway Account", {"payment_gateway": "Mpesa-Payment"}, "payment_account"
)
frappe.db.set_value("Account", mpesa_account, "account_currency", "KES")
frappe.db.set_value("Mpesa Settings", "Payment", "transaction_limit", "500")
frappe.db.set_value("Customer", "_Test Customer", "default_currency", "KES")
pos_invoice = create_pos_invoice(do_not_submit=1)
pos_invoice.append("payments", {'mode_of_payment': 'Mpesa-Payment', 'account': mpesa_account, 'amount': 1000})
pos_invoice.append(
"payments", {"mode_of_payment": "Mpesa-Payment", "account": mpesa_account, "amount": 1000}
)
pos_invoice.contact_mobile = "093456543894"
pos_invoice.currency = "KES"
pos_invoice.save()
@@ -115,10 +134,14 @@ class TestMpesaSettings(unittest.TestCase):
self.assertEqual(pr.payment_gateway, "Mpesa-Payment")
# submitting payment request creates integration requests with random id
integration_req_ids = frappe.get_all("Integration Request", filters={
'reference_doctype': pr.doctype,
'reference_docname': pr.name,
}, pluck="name")
integration_req_ids = frappe.get_all(
"Integration Request",
filters={
"reference_doctype": pr.doctype,
"reference_docname": pr.name,
},
pluck="name",
)
# create random receipt nos and send it as response to callback handler
mpesa_receipt_numbers = [frappe.utils.random_string(5) for d in integration_req_ids]
@@ -128,7 +151,7 @@ class TestMpesaSettings(unittest.TestCase):
callback_response = get_payment_callback_payload(
Amount=500,
CheckoutRequestID=integration_req_ids[i],
MpesaReceiptNumber=mpesa_receipt_numbers[i]
MpesaReceiptNumber=mpesa_receipt_numbers[i],
)
# handle response manually
verify_transaction(**callback_response)
@@ -139,7 +162,7 @@ class TestMpesaSettings(unittest.TestCase):
# check receipt number once all the integration requests are completed
pos_invoice.reload()
self.assertEqual(pos_invoice.mpesa_receipt_number, ', '.join(mpesa_receipt_numbers))
self.assertEqual(pos_invoice.mpesa_receipt_number, ", ".join(mpesa_receipt_numbers))
frappe.db.set_value("Customer", "_Test Customer", "default_currency", "")
[d.delete() for d in integration_requests]
@@ -149,13 +172,17 @@ class TestMpesaSettings(unittest.TestCase):
pos_invoice.delete()
def test_processing_of_only_one_succes_callback_payload(self):
mpesa_account = frappe.db.get_value("Payment Gateway Account", {"payment_gateway": 'Mpesa-Payment'}, "payment_account")
mpesa_account = frappe.db.get_value(
"Payment Gateway Account", {"payment_gateway": "Mpesa-Payment"}, "payment_account"
)
frappe.db.set_value("Account", mpesa_account, "account_currency", "KES")
frappe.db.set_value("Mpesa Settings", "Payment", "transaction_limit", "500")
frappe.db.set_value("Customer", "_Test Customer", "default_currency", "KES")
pos_invoice = create_pos_invoice(do_not_submit=1)
pos_invoice.append("payments", {'mode_of_payment': 'Mpesa-Payment', 'account': mpesa_account, 'amount': 1000})
pos_invoice.append(
"payments", {"mode_of_payment": "Mpesa-Payment", "account": mpesa_account, "amount": 1000}
)
pos_invoice.contact_mobile = "093456543894"
pos_invoice.currency = "KES"
pos_invoice.save()
@@ -165,10 +192,14 @@ class TestMpesaSettings(unittest.TestCase):
self.assertEqual(pr.payment_gateway, "Mpesa-Payment")
# submitting payment request creates integration requests with random id
integration_req_ids = frappe.get_all("Integration Request", filters={
'reference_doctype': pr.doctype,
'reference_docname': pr.name,
}, pluck="name")
integration_req_ids = frappe.get_all(
"Integration Request",
filters={
"reference_doctype": pr.doctype,
"reference_docname": pr.name,
},
pluck="name",
)
# create random receipt nos and send it as response to callback handler
mpesa_receipt_numbers = [frappe.utils.random_string(5) for d in integration_req_ids]
@@ -176,7 +207,7 @@ class TestMpesaSettings(unittest.TestCase):
callback_response = get_payment_callback_payload(
Amount=500,
CheckoutRequestID=integration_req_ids[0],
MpesaReceiptNumber=mpesa_receipt_numbers[0]
MpesaReceiptNumber=mpesa_receipt_numbers[0],
)
# handle response manually
verify_transaction(**callback_response)
@@ -188,11 +219,15 @@ class TestMpesaSettings(unittest.TestCase):
# second integration request fails
# now retrying payment request should make only one integration request again
pr = pos_invoice.create_payment_request()
new_integration_req_ids = frappe.get_all("Integration Request", filters={
'reference_doctype': pr.doctype,
'reference_docname': pr.name,
'name': ['not in', integration_req_ids]
}, pluck="name")
new_integration_req_ids = frappe.get_all(
"Integration Request",
filters={
"reference_doctype": pr.doctype,
"reference_docname": pr.name,
"name": ["not in", integration_req_ids],
},
pluck="name",
)
self.assertEqual(len(new_integration_req_ids), 1)
@@ -203,94 +238,56 @@ class TestMpesaSettings(unittest.TestCase):
pr.delete()
pos_invoice.delete()
def create_mpesa_settings(payment_gateway_name="Express"):
if frappe.db.exists("Mpesa Settings", payment_gateway_name):
return frappe.get_doc("Mpesa Settings", payment_gateway_name)
doc = frappe.get_doc(dict( #nosec
doctype="Mpesa Settings",
sandbox=1,
payment_gateway_name=payment_gateway_name,
consumer_key="5sMu9LVI1oS3oBGPJfh3JyvLHwZOdTKn",
consumer_secret="VI1oS3oBGPJfh3JyvLHw",
online_passkey="LVI1oS3oBGPJfh3JyvLHwZOd",
till_number="174379"
))
doc = frappe.get_doc(
dict( # nosec
doctype="Mpesa Settings",
sandbox=1,
payment_gateway_name=payment_gateway_name,
consumer_key="5sMu9LVI1oS3oBGPJfh3JyvLHwZOdTKn",
consumer_secret="VI1oS3oBGPJfh3JyvLHw",
online_passkey="LVI1oS3oBGPJfh3JyvLHwZOd",
till_number="174379",
)
)
doc.insert(ignore_permissions=True)
return doc
def get_test_account_balance_response():
"""Response received after calling the account balance API."""
return {
"ResultType":0,
"ResultCode":0,
"ResultDesc":"The service request has been accepted successfully.",
"OriginatorConversationID":"10816-694520-2",
"ConversationID":"AG_20200927_00007cdb1f9fb6494315",
"TransactionID":"LGR0000000",
"ResultParameters":{
"ResultParameter":[
{
"Key":"ReceiptNo",
"Value":"LGR919G2AV"
},
{
"Key":"Conversation ID",
"Value":"AG_20170727_00004492b1b6d0078fbe"
},
{
"Key":"FinalisedTime",
"Value":20170727101415
},
{
"Key":"Amount",
"Value":10
},
{
"Key":"TransactionStatus",
"Value":"Completed"
},
{
"Key":"ReasonType",
"Value":"Salary Payment via API"
},
{
"Key":"TransactionReason"
},
{
"Key":"DebitPartyCharges",
"Value":"Fee For B2C Payment|KES|33.00"
},
{
"Key":"DebitAccountType",
"Value":"Utility Account"
},
{
"Key":"InitiatedTime",
"Value":20170727101415
},
{
"Key":"Originator Conversation ID",
"Value":"19455-773836-1"
},
{
"Key":"CreditPartyName",
"Value":"254708374149 - John Doe"
},
{
"Key":"DebitPartyName",
"Value":"600134 - Safaricom157"
}
]
},
"ReferenceData":{
"ReferenceItem":{
"Key":"Occasion",
"Value":"aaaa"
"ResultType": 0,
"ResultCode": 0,
"ResultDesc": "The service request has been accepted successfully.",
"OriginatorConversationID": "10816-694520-2",
"ConversationID": "AG_20200927_00007cdb1f9fb6494315",
"TransactionID": "LGR0000000",
"ResultParameters": {
"ResultParameter": [
{"Key": "ReceiptNo", "Value": "LGR919G2AV"},
{"Key": "Conversation ID", "Value": "AG_20170727_00004492b1b6d0078fbe"},
{"Key": "FinalisedTime", "Value": 20170727101415},
{"Key": "Amount", "Value": 10},
{"Key": "TransactionStatus", "Value": "Completed"},
{"Key": "ReasonType", "Value": "Salary Payment via API"},
{"Key": "TransactionReason"},
{"Key": "DebitPartyCharges", "Value": "Fee For B2C Payment|KES|33.00"},
{"Key": "DebitAccountType", "Value": "Utility Account"},
{"Key": "InitiatedTime", "Value": 20170727101415},
{"Key": "Originator Conversation ID", "Value": "19455-773836-1"},
{"Key": "CreditPartyName", "Value": "254708374149 - John Doe"},
{"Key": "DebitPartyName", "Value": "600134 - Safaricom157"},
]
},
"ReferenceData": {"ReferenceItem": {"Key": "Occasion", "Value": "aaaa"}},
}
}
}
def get_payment_request_response_payload(Amount=500):
"""Response received after successfully calling the stk push process request API."""
@@ -304,40 +301,44 @@ def get_payment_request_response_payload(Amount=500):
"ResultDesc": "The service request is processed successfully.",
"CallbackMetadata": {
"Item": [
{ "Name": "Amount", "Value": Amount },
{ "Name": "MpesaReceiptNumber", "Value": "LGR7OWQX0R" },
{ "Name": "TransactionDate", "Value": 20201006113336 },
{ "Name": "PhoneNumber", "Value": 254723575670 }
{"Name": "Amount", "Value": Amount},
{"Name": "MpesaReceiptNumber", "Value": "LGR7OWQX0R"},
{"Name": "TransactionDate", "Value": 20201006113336},
{"Name": "PhoneNumber", "Value": 254723575670},
]
}
},
}
def get_payment_callback_payload(Amount=500, CheckoutRequestID="ws_CO_061020201133231972", MpesaReceiptNumber="LGR7OWQX0R"):
def get_payment_callback_payload(
Amount=500, CheckoutRequestID="ws_CO_061020201133231972", MpesaReceiptNumber="LGR7OWQX0R"
):
"""Response received from the server as callback after calling the stkpush process request API."""
return {
"Body":{
"stkCallback":{
"MerchantRequestID":"19465-780693-1",
"CheckoutRequestID":CheckoutRequestID,
"ResultCode":0,
"ResultDesc":"The service request is processed successfully.",
"CallbackMetadata":{
"Item":[
{ "Name":"Amount", "Value":Amount },
{ "Name":"MpesaReceiptNumber", "Value":MpesaReceiptNumber },
{ "Name":"Balance" },
{ "Name":"TransactionDate", "Value":20170727154800 },
{ "Name":"PhoneNumber", "Value":254721566839 }
"Body": {
"stkCallback": {
"MerchantRequestID": "19465-780693-1",
"CheckoutRequestID": CheckoutRequestID,
"ResultCode": 0,
"ResultDesc": "The service request is processed successfully.",
"CallbackMetadata": {
"Item": [
{"Name": "Amount", "Value": Amount},
{"Name": "MpesaReceiptNumber", "Value": MpesaReceiptNumber},
{"Name": "Balance"},
{"Name": "TransactionDate", "Value": 20170727154800},
{"Name": "PhoneNumber", "Value": 254721566839},
]
}
},
}
}
}
def get_account_balance_callback_payload():
"""Response received from the server as callback after calling the account balance API."""
return {
"Result":{
"Result": {
"ResultType": 0,
"ResultCode": 0,
"ResultDesc": "The service request is processed successfully.",
@@ -346,18 +347,15 @@ def get_account_balance_callback_payload():
"TransactionID": "OIR0000000",
"ResultParameters": {
"ResultParameter": [
{
"Key": "AccountBalance",
"Value": "Working Account|KES|481000.00|481000.00|0.00|0.00"
},
{ "Key": "BOCompletedTime", "Value": 20200927234123 }
{"Key": "AccountBalance", "Value": "Working Account|KES|481000.00|481000.00|0.00|0.00"},
{"Key": "BOCompletedTime", "Value": 20200927234123},
]
},
"ReferenceData": {
"ReferenceItem": {
"Key": "QueueTimeoutURL",
"Value": "https://internalsandbox.safaricom.co.ke/mpesa/abresults/v1/submit"
"Value": "https://internalsandbox.safaricom.co.ke/mpesa/abresults/v1/submit",
}
}
},
}
}

View File

@@ -8,7 +8,7 @@ from frappe import _
from plaid.errors import APIError, InvalidRequestError, ItemError
class PlaidConnector():
class PlaidConnector:
def __init__(self, access_token=None):
self.access_token = access_token
self.settings = frappe.get_single("Plaid Settings")
@@ -18,7 +18,7 @@ class PlaidConnector():
client_id=self.settings.plaid_client_id,
secret=self.settings.get_password("plaid_secret"),
environment=self.settings.plaid_env,
api_version="2020-09-14"
api_version="2020-09-14",
)
def get_access_token(self, public_token):
@@ -29,25 +29,29 @@ class PlaidConnector():
return access_token
def get_token_request(self, update_mode=False):
country_codes = ["US", "CA", "FR", "IE", "NL", "ES", "GB"] if self.settings.enable_european_access else ["US", "CA"]
country_codes = (
["US", "CA", "FR", "IE", "NL", "ES", "GB"]
if self.settings.enable_european_access
else ["US", "CA"]
)
args = {
"client_name": self.client_name,
# only allow Plaid-supported languages and countries (LAST: Sep-19-2020)
"language": frappe.local.lang if frappe.local.lang in ["en", "fr", "es", "nl"] else "en",
"country_codes": country_codes,
"user": {
"client_user_id": frappe.generate_hash(frappe.session.user, length=32)
}
"user": {"client_user_id": frappe.generate_hash(frappe.session.user, length=32)},
}
if update_mode:
args["access_token"] = self.access_token
else:
args.update({
"client_id": self.settings.plaid_client_id,
"secret": self.settings.plaid_secret,
"products": self.products,
})
args.update(
{
"client_id": self.settings.plaid_client_id,
"secret": self.settings.plaid_secret,
"products": self.products,
}
)
return args
@@ -82,11 +86,7 @@ class PlaidConnector():
def get_transactions(self, start_date, end_date, account_id=None):
self.auth()
kwargs = dict(
access_token=self.access_token,
start_date=start_date,
end_date=end_date
)
kwargs = dict(access_token=self.access_token, start_date=start_date, end_date=end_date)
if account_id:
kwargs.update(dict(account_ids=[account_id]))
@@ -94,7 +94,9 @@ class PlaidConnector():
response = self.client.Transactions.get(**kwargs)
transactions = response["transactions"]
while len(transactions) < response["total_transactions"]:
response = self.client.Transactions.get(self.access_token, start_date=start_date, end_date=end_date, offset=len(transactions))
response = self.client.Transactions.get(
self.access_token, start_date=start_date, end_date=end_date, offset=len(transactions)
)
transactions.extend(response["transactions"])
return transactions
except ItemError as e:

View File

@@ -29,7 +29,7 @@ def get_plaid_configuration():
return {
"plaid_env": plaid_settings.plaid_env,
"link_token": plaid_settings.get_link_token(),
"client_name": frappe.local.site
"client_name": frappe.local.site,
}
return "disabled"
@@ -45,14 +45,16 @@ def add_institution(token, response):
if not frappe.db.exists("Bank", response["institution"]["name"]):
try:
bank = frappe.get_doc({
"doctype": "Bank",
"bank_name": response["institution"]["name"],
"plaid_access_token": access_token
})
bank = frappe.get_doc(
{
"doctype": "Bank",
"bank_name": response["institution"]["name"],
"plaid_access_token": access_token,
}
)
bank.insert()
except Exception:
frappe.log_error(frappe.get_traceback(), title=_('Plaid Link Error'))
frappe.log_error(frappe.get_traceback(), title=_("Plaid Link Error"))
else:
bank = frappe.get_doc("Bank", response["institution"]["name"])
bank.plaid_access_token = access_token
@@ -89,65 +91,71 @@ def add_bank_accounts(response, bank, company):
if not existing_bank_account:
try:
new_account = frappe.get_doc({
"doctype": "Bank Account",
"bank": bank["bank_name"],
"account": default_gl_account.account,
"account_name": account["name"],
"account_type": account.get("type", ""),
"account_subtype": account.get("subtype", ""),
"mask": account.get("mask", ""),
"integration_id": account["id"],
"is_company_account": 1,
"company": company
})
new_account = frappe.get_doc(
{
"doctype": "Bank Account",
"bank": bank["bank_name"],
"account": default_gl_account.account,
"account_name": account["name"],
"account_type": account.get("type", ""),
"account_subtype": account.get("subtype", ""),
"mask": account.get("mask", ""),
"integration_id": account["id"],
"is_company_account": 1,
"company": company,
}
)
new_account.insert()
result.append(new_account.name)
except frappe.UniqueValidationError:
frappe.msgprint(_("Bank account {0} already exists and could not be created again").format(account["name"]))
frappe.msgprint(
_("Bank account {0} already exists and could not be created again").format(account["name"])
)
except Exception:
frappe.log_error(frappe.get_traceback(), title=_("Plaid Link Error"))
frappe.throw(_("There was an error creating Bank Account while linking with Plaid."),
title=_("Plaid Link Failed"))
frappe.throw(
_("There was an error creating Bank Account while linking with Plaid."),
title=_("Plaid Link Failed"),
)
else:
try:
existing_account = frappe.get_doc('Bank Account', existing_bank_account)
existing_account.update({
"bank": bank["bank_name"],
"account_name": account["name"],
"account_type": account.get("type", ""),
"account_subtype": account.get("subtype", ""),
"mask": account.get("mask", ""),
"integration_id": account["id"]
})
existing_account = frappe.get_doc("Bank Account", existing_bank_account)
existing_account.update(
{
"bank": bank["bank_name"],
"account_name": account["name"],
"account_type": account.get("type", ""),
"account_subtype": account.get("subtype", ""),
"mask": account.get("mask", ""),
"integration_id": account["id"],
}
)
existing_account.save()
result.append(existing_bank_account)
except Exception:
frappe.log_error(frappe.get_traceback(), title=_("Plaid Link Error"))
frappe.throw(_("There was an error updating Bank Account {} while linking with Plaid.").format(
existing_bank_account), title=_("Plaid Link Failed"))
frappe.throw(
_("There was an error updating Bank Account {} while linking with Plaid.").format(
existing_bank_account
),
title=_("Plaid Link Failed"),
)
return result
def add_account_type(account_type):
try:
frappe.get_doc({
"doctype": "Bank Account Type",
"account_type": account_type
}).insert()
frappe.get_doc({"doctype": "Bank Account Type", "account_type": account_type}).insert()
except Exception:
frappe.throw(frappe.get_traceback())
def add_account_subtype(account_subtype):
try:
frappe.get_doc({
"doctype": "Bank Account Subtype",
"account_subtype": account_subtype
}).insert()
frappe.get_doc({"doctype": "Bank Account Subtype", "account_subtype": account_subtype}).insert()
except Exception:
frappe.throw(frappe.get_traceback())
@@ -164,19 +172,26 @@ def sync_transactions(bank, bank_account):
end_date = formatdate(today(), "YYYY-MM-dd")
try:
transactions = get_transactions(bank=bank, bank_account=bank_account, start_date=start_date, end_date=end_date)
transactions = get_transactions(
bank=bank, bank_account=bank_account, start_date=start_date, end_date=end_date
)
result = []
for transaction in reversed(transactions):
result += new_bank_transaction(transaction)
if result:
last_transaction_date = frappe.db.get_value('Bank Transaction', result.pop(), 'date')
last_transaction_date = frappe.db.get_value("Bank Transaction", result.pop(), "date")
frappe.logger().info("Plaid added {} new Bank Transactions from '{}' between {} and {}".format(
len(result), bank_account, start_date, end_date))
frappe.logger().info(
"Plaid added {} new Bank Transactions from '{}' between {} and {}".format(
len(result), bank_account, start_date, end_date
)
)
frappe.db.set_value("Bank Account", bank_account, "last_integration_date", last_transaction_date)
frappe.db.set_value(
"Bank Account", bank_account, "last_integration_date", last_transaction_date
)
except Exception:
frappe.log_error(frappe.get_traceback(), _("Plaid transactions sync error"))
@@ -185,7 +200,9 @@ def get_transactions(bank, bank_account=None, start_date=None, end_date=None):
access_token = None
if bank_account:
related_bank = frappe.db.get_values("Bank Account", bank_account, ["bank", "integration_id"], as_dict=True)
related_bank = frappe.db.get_values(
"Bank Account", bank_account, ["bank", "integration_id"], as_dict=True
)
access_token = frappe.db.get_value("Bank", related_bank[0].bank, "plaid_access_token")
account_id = related_bank[0].integration_id
else:
@@ -196,7 +213,9 @@ def get_transactions(bank, bank_account=None, start_date=None, end_date=None):
transactions = []
try:
transactions = plaid.get_transactions(start_date=start_date, end_date=end_date, account_id=account_id)
transactions = plaid.get_transactions(
start_date=start_date, end_date=end_date, account_id=account_id
)
except ItemError as e:
if e.code == "ITEM_LOGIN_REQUIRED":
msg = _("There was an error syncing transactions.") + " "
@@ -229,18 +248,20 @@ def new_bank_transaction(transaction):
if not frappe.db.exists("Bank Transaction", dict(transaction_id=transaction["transaction_id"])):
try:
new_transaction = frappe.get_doc({
"doctype": "Bank Transaction",
"date": getdate(transaction["date"]),
"status": status,
"bank_account": bank_account,
"deposit": debit,
"withdrawal": credit,
"currency": transaction["iso_currency_code"],
"transaction_id": transaction["transaction_id"],
"reference_number": transaction["payment_meta"]["reference_number"],
"description": transaction["name"]
})
new_transaction = frappe.get_doc(
{
"doctype": "Bank Transaction",
"date": getdate(transaction["date"]),
"status": status,
"bank_account": bank_account,
"deposit": debit,
"withdrawal": credit,
"currency": transaction["iso_currency_code"],
"transaction_id": transaction["transaction_id"],
"reference_number": transaction["payment_meta"]["reference_number"],
"description": transaction["name"],
}
)
new_transaction.insert()
new_transaction.submit()
@@ -250,7 +271,7 @@ def new_bank_transaction(transaction):
result.append(new_transaction.name)
except Exception:
frappe.throw(title=_('Bank transaction creation error'))
frappe.throw(title=_("Bank transaction creation error"))
return result
@@ -260,19 +281,21 @@ def automatic_synchronization():
if settings.enabled == 1 and settings.automatic_sync == 1:
enqueue_synchronization()
@frappe.whitelist()
def enqueue_synchronization():
plaid_accounts = frappe.get_all("Bank Account",
filters={"integration_id": ["!=", ""]},
fields=["name", "bank"])
plaid_accounts = frappe.get_all(
"Bank Account", filters={"integration_id": ["!=", ""]}, fields=["name", "bank"]
)
for plaid_account in plaid_accounts:
frappe.enqueue(
"erpnext.erpnext_integrations.doctype.plaid_settings.plaid_settings.sync_transactions",
bank=plaid_account.bank,
bank_account=plaid_account.name
bank_account=plaid_account.name,
)
@frappe.whitelist()
def get_link_token_for_update(access_token):
plaid = PlaidConnector(access_token)

View File

@@ -45,111 +45,110 @@ class TestPlaidSettings(unittest.TestCase):
def test_default_bank_account(self):
if not frappe.db.exists("Bank", "Citi"):
frappe.get_doc({
"doctype": "Bank",
"bank_name": "Citi"
}).insert()
frappe.get_doc({"doctype": "Bank", "bank_name": "Citi"}).insert()
bank_accounts = {
'account': {
'subtype': 'checking',
'mask': '0000',
'type': 'depository',
'id': '6GbM6RRQgdfy3lAqGz4JUnpmR948WZFg8DjQK',
'name': 'Plaid Checking'
"account": {
"subtype": "checking",
"mask": "0000",
"type": "depository",
"id": "6GbM6RRQgdfy3lAqGz4JUnpmR948WZFg8DjQK",
"name": "Plaid Checking",
},
'account_id': '6GbM6RRQgdfy3lAqGz4JUnpmR948WZFg8DjQK',
'link_session_id': 'db673d75-61aa-442a-864f-9b3f174f3725',
'accounts': [{
'type': 'depository',
'subtype': 'checking',
'mask': '0000',
'id': '6GbM6RRQgdfy3lAqGz4JUnpmR948WZFg8DjQK',
'name': 'Plaid Checking'
}],
'institution': {
'institution_id': 'ins_6',
'name': 'Citi'
}
"account_id": "6GbM6RRQgdfy3lAqGz4JUnpmR948WZFg8DjQK",
"link_session_id": "db673d75-61aa-442a-864f-9b3f174f3725",
"accounts": [
{
"type": "depository",
"subtype": "checking",
"mask": "0000",
"id": "6GbM6RRQgdfy3lAqGz4JUnpmR948WZFg8DjQK",
"name": "Plaid Checking",
}
],
"institution": {"institution_id": "ins_6", "name": "Citi"},
}
bank = json.dumps(frappe.get_doc("Bank", "Citi").as_dict(), default=json_handler)
company = frappe.db.get_single_value('Global Defaults', 'default_company')
company = frappe.db.get_single_value("Global Defaults", "default_company")
frappe.db.set_value("Company", company, "default_bank_account", None)
self.assertRaises(frappe.ValidationError, add_bank_accounts, response=bank_accounts, bank=bank, company=company)
self.assertRaises(
frappe.ValidationError, add_bank_accounts, response=bank_accounts, bank=bank, company=company
)
def test_new_transaction(self):
if not frappe.db.exists("Bank", "Citi"):
frappe.get_doc({
"doctype": "Bank",
"bank_name": "Citi"
}).insert()
frappe.get_doc({"doctype": "Bank", "bank_name": "Citi"}).insert()
bank_accounts = {
'account': {
'subtype': 'checking',
'mask': '0000',
'type': 'depository',
'id': '6GbM6RRQgdfy3lAqGz4JUnpmR948WZFg8DjQK',
'name': 'Plaid Checking'
"account": {
"subtype": "checking",
"mask": "0000",
"type": "depository",
"id": "6GbM6RRQgdfy3lAqGz4JUnpmR948WZFg8DjQK",
"name": "Plaid Checking",
},
'account_id': '6GbM6RRQgdfy3lAqGz4JUnpmR948WZFg8DjQK',
'link_session_id': 'db673d75-61aa-442a-864f-9b3f174f3725',
'accounts': [{
'type': 'depository',
'subtype': 'checking',
'mask': '0000',
'id': '6GbM6RRQgdfy3lAqGz4JUnpmR948WZFg8DjQK',
'name': 'Plaid Checking'
}],
'institution': {
'institution_id': 'ins_6',
'name': 'Citi'
}
"account_id": "6GbM6RRQgdfy3lAqGz4JUnpmR948WZFg8DjQK",
"link_session_id": "db673d75-61aa-442a-864f-9b3f174f3725",
"accounts": [
{
"type": "depository",
"subtype": "checking",
"mask": "0000",
"id": "6GbM6RRQgdfy3lAqGz4JUnpmR948WZFg8DjQK",
"name": "Plaid Checking",
}
],
"institution": {"institution_id": "ins_6", "name": "Citi"},
}
bank = json.dumps(frappe.get_doc("Bank", "Citi").as_dict(), default=json_handler)
company = frappe.db.get_single_value('Global Defaults', 'default_company')
company = frappe.db.get_single_value("Global Defaults", "default_company")
if frappe.db.get_value("Company", company, "default_bank_account") is None:
frappe.db.set_value("Company", company, "default_bank_account", get_default_bank_cash_account(company, "Cash").get("account"))
frappe.db.set_value(
"Company",
company,
"default_bank_account",
get_default_bank_cash_account(company, "Cash").get("account"),
)
add_bank_accounts(bank_accounts, bank, company)
transactions = {
'account_owner': None,
'category': ['Food and Drink', 'Restaurants'],
'account_id': 'b4Jkp1LJDZiPgojpr1ansXJrj5Q6w9fVmv6ov',
'pending_transaction_id': None,
'transaction_id': 'x374xPa7DvUewqlR5mjNIeGK8r8rl3Sn647LM',
'unofficial_currency_code': None,
'name': 'INTRST PYMNT',
'transaction_type': 'place',
'amount': -4.22,
'location': {
'city': None,
'zip': None,
'store_number': None,
'lon': None,
'state': None,
'address': None,
'lat': None
"account_owner": None,
"category": ["Food and Drink", "Restaurants"],
"account_id": "b4Jkp1LJDZiPgojpr1ansXJrj5Q6w9fVmv6ov",
"pending_transaction_id": None,
"transaction_id": "x374xPa7DvUewqlR5mjNIeGK8r8rl3Sn647LM",
"unofficial_currency_code": None,
"name": "INTRST PYMNT",
"transaction_type": "place",
"amount": -4.22,
"location": {
"city": None,
"zip": None,
"store_number": None,
"lon": None,
"state": None,
"address": None,
"lat": None,
},
'payment_meta': {
'reference_number': None,
'payer': None,
'payment_method': None,
'reason': None,
'payee': None,
'ppd_id': None,
'payment_processor': None,
'by_order_of': None
"payment_meta": {
"reference_number": None,
"payer": None,
"payment_method": None,
"reason": None,
"payee": None,
"ppd_id": None,
"payment_processor": None,
"by_order_of": None,
},
'date': '2017-12-22',
'category_id': '13005000',
'pending': False,
'iso_currency_code': 'USD'
"date": "2017-12-22",
"category_id": "13005000",
"pending": False,
"iso_currency_code": "USD",
}
new_bank_transaction(transactions)

View File

@@ -36,6 +36,7 @@ def new_doc(document):
return doc
class TallyMigration(Document):
def validate(self):
failed_import_log = json.loads(self.failed_import_log)
@@ -73,23 +74,29 @@ class TallyMigration(Document):
def dump_processed_data(self, data):
for key, value in data.items():
f = frappe.get_doc({
"doctype": "File",
"file_name": key + ".json",
"attached_to_doctype": self.doctype,
"attached_to_name": self.name,
"content": json.dumps(value),
"is_private": True
})
f = frappe.get_doc(
{
"doctype": "File",
"file_name": key + ".json",
"attached_to_doctype": self.doctype,
"attached_to_name": self.name,
"content": json.dumps(value),
"is_private": True,
}
)
try:
f.insert()
f.insert(ignore_if_duplicate=True)
except frappe.DuplicateEntryError:
pass
setattr(self, key, f.file_url)
def set_account_defaults(self):
self.default_cost_center, self.default_round_off_account = frappe.db.get_value("Company", self.erpnext_company, ["cost_center", "round_off_account"])
self.default_warehouse = frappe.db.get_value("Stock Settings", "Stock Settings", "default_warehouse")
self.default_cost_center, self.default_round_off_account = frappe.db.get_value(
"Company", self.erpnext_company, ["cost_center", "round_off_account"]
)
self.default_warehouse = frappe.db.get_value(
"Stock Settings", "Stock Settings", "default_warehouse"
)
def _process_master_data(self):
def get_company_name(collection):
@@ -100,18 +107,24 @@ class TallyMigration(Document):
"Application of Funds (Assets)": "Asset",
"Expenses": "Expense",
"Income": "Income",
"Source of Funds (Liabilities)": "Liability"
"Source of Funds (Liabilities)": "Liability",
}
roots = set(root_type_map.keys())
accounts = list(get_groups(collection.find_all("GROUP"))) + list(get_ledgers(collection.find_all("LEDGER")))
accounts = list(get_groups(collection.find_all("GROUP"))) + list(
get_ledgers(collection.find_all("LEDGER"))
)
children, parents = get_children_and_parent_dict(accounts)
group_set = [acc[1] for acc in accounts if acc[2]]
group_set = [acc[1] for acc in accounts if acc[2]]
children, customers, suppliers = remove_parties(parents, children, group_set)
try:
coa = traverse({}, children, roots, roots, group_set)
except RecursionError:
self.log(_("Error occured while parsing Chart of Accounts: Please make sure that no two accounts have the same name"))
self.log(
_(
"Error occured while parsing Chart of Accounts: Please make sure that no two accounts have the same name"
)
)
for account in coa:
coa[account]["root_type"] = root_type_map[account]
@@ -185,42 +198,48 @@ class TallyMigration(Document):
links = []
if account.NAME.string.strip() in customers:
party_type = "Customer"
parties.append({
"doctype": party_type,
"customer_name": account.NAME.string.strip(),
"tax_id": account.INCOMETAXNUMBER.string.strip() if account.INCOMETAXNUMBER else None,
"customer_group": "All Customer Groups",
"territory": "All Territories",
"customer_type": "Individual",
})
parties.append(
{
"doctype": party_type,
"customer_name": account.NAME.string.strip(),
"tax_id": account.INCOMETAXNUMBER.string.strip() if account.INCOMETAXNUMBER else None,
"customer_group": "All Customer Groups",
"territory": "All Territories",
"customer_type": "Individual",
}
)
links.append({"link_doctype": party_type, "link_name": account["NAME"]})
if account.NAME.string.strip() in suppliers:
party_type = "Supplier"
parties.append({
"doctype": party_type,
"supplier_name": account.NAME.string.strip(),
"pan": account.INCOMETAXNUMBER.string.strip() if account.INCOMETAXNUMBER else None,
"supplier_group": "All Supplier Groups",
"supplier_type": "Individual",
})
parties.append(
{
"doctype": party_type,
"supplier_name": account.NAME.string.strip(),
"pan": account.INCOMETAXNUMBER.string.strip() if account.INCOMETAXNUMBER else None,
"supplier_group": "All Supplier Groups",
"supplier_type": "Individual",
}
)
links.append({"link_doctype": party_type, "link_name": account["NAME"]})
if party_type:
address = "\n".join([a.string.strip() for a in account.find_all("ADDRESS")])
addresses.append({
"doctype": "Address",
"address_line1": address[:140].strip(),
"address_line2": address[140:].strip(),
"country": account.COUNTRYNAME.string.strip() if account.COUNTRYNAME else None,
"state": account.LEDSTATENAME.string.strip() if account.LEDSTATENAME else None,
"gst_state": account.LEDSTATENAME.string.strip() if account.LEDSTATENAME else None,
"pin_code": account.PINCODE.string.strip() if account.PINCODE else None,
"mobile": account.LEDGERPHONE.string.strip() if account.LEDGERPHONE else None,
"phone": account.LEDGERPHONE.string.strip() if account.LEDGERPHONE else None,
"gstin": account.PARTYGSTIN.string.strip() if account.PARTYGSTIN else None,
"links": links
})
addresses.append(
{
"doctype": "Address",
"address_line1": address[:140].strip(),
"address_line2": address[140:].strip(),
"country": account.COUNTRYNAME.string.strip() if account.COUNTRYNAME else None,
"state": account.LEDSTATENAME.string.strip() if account.LEDSTATENAME else None,
"gst_state": account.LEDSTATENAME.string.strip() if account.LEDSTATENAME else None,
"pin_code": account.PINCODE.string.strip() if account.PINCODE else None,
"mobile": account.LEDGERPHONE.string.strip() if account.LEDGERPHONE else None,
"phone": account.LEDGERPHONE.string.strip() if account.LEDGERPHONE else None,
"gstin": account.PARTYGSTIN.string.strip() if account.PARTYGSTIN else None,
"links": links,
}
)
return parties, addresses
def get_stock_items_uoms(collection):
@@ -231,14 +250,16 @@ class TallyMigration(Document):
items = []
for item in collection.find_all("STOCKITEM"):
stock_uom = item.BASEUNITS.string.strip() if item.BASEUNITS else self.default_uom
items.append({
"doctype": "Item",
"item_code" : item.NAME.string.strip(),
"stock_uom": stock_uom.strip(),
"is_stock_item": 0,
"item_group": "All Item Groups",
"item_defaults": [{"company": self.erpnext_company}]
})
items.append(
{
"doctype": "Item",
"item_code": item.NAME.string.strip(),
"stock_uom": stock_uom.strip(),
"is_stock_item": 0,
"item_group": "All Item Groups",
"item_defaults": [{"company": self.erpnext_company}],
}
)
return items, uoms
@@ -257,7 +278,13 @@ class TallyMigration(Document):
self.publish("Process Master Data", _("Processing Items and UOMs"), 4, 5)
items, uoms = get_stock_items_uoms(collection)
data = {"chart_of_accounts": chart_of_accounts, "parties": parties, "addresses": addresses, "items": items, "uoms": uoms}
data = {
"chart_of_accounts": chart_of_accounts,
"parties": parties,
"addresses": addresses,
"items": items,
"uoms": uoms,
}
self.publish("Process Master Data", _("Done"), 5, 5)
self.dump_processed_data(data)
@@ -272,7 +299,10 @@ class TallyMigration(Document):
self.set_status()
def publish(self, title, message, count, total):
frappe.publish_realtime("tally_migration_progress_update", {"title": title, "message": message, "count": count, "total": total})
frappe.publish_realtime(
"tally_migration_progress_update",
{"title": title, "message": message, "count": count, "total": total},
)
def _import_master_data(self):
def create_company_and_coa(coa_file_url):
@@ -280,12 +310,14 @@ class TallyMigration(Document):
frappe.local.flags.ignore_chart_of_accounts = True
try:
company = frappe.get_doc({
"doctype": "Company",
"company_name": self.erpnext_company,
"default_currency": "INR",
"enable_perpetual_inventory": 0,
}).insert()
company = frappe.get_doc(
{
"doctype": "Company",
"company_name": self.erpnext_company,
"default_currency": "INR",
"enable_perpetual_inventory": 0,
}
).insert()
except frappe.DuplicateEntryError:
company = frappe.get_doc("Company", self.erpnext_company)
unset_existing_data(self.erpnext_company)
@@ -358,8 +390,16 @@ class TallyMigration(Document):
for voucher in collection.find_all("VOUCHER"):
if voucher.ISCANCELLED.string.strip() == "Yes":
continue
inventory_entries = voucher.find_all("INVENTORYENTRIES.LIST") + voucher.find_all("ALLINVENTORYENTRIES.LIST") + voucher.find_all("INVENTORYENTRIESIN.LIST") + voucher.find_all("INVENTORYENTRIESOUT.LIST")
if voucher.VOUCHERTYPENAME.string.strip() not in ["Journal", "Receipt", "Payment", "Contra"] and inventory_entries:
inventory_entries = (
voucher.find_all("INVENTORYENTRIES.LIST")
+ voucher.find_all("ALLINVENTORYENTRIES.LIST")
+ voucher.find_all("INVENTORYENTRIESIN.LIST")
+ voucher.find_all("INVENTORYENTRIESOUT.LIST")
)
if (
voucher.VOUCHERTYPENAME.string.strip() not in ["Journal", "Receipt", "Payment", "Contra"]
and inventory_entries
):
function = voucher_to_invoice
else:
function = voucher_to_journal_entry
@@ -375,9 +415,14 @@ class TallyMigration(Document):
def voucher_to_journal_entry(voucher):
accounts = []
ledger_entries = voucher.find_all("ALLLEDGERENTRIES.LIST") + voucher.find_all("LEDGERENTRIES.LIST")
ledger_entries = voucher.find_all("ALLLEDGERENTRIES.LIST") + voucher.find_all(
"LEDGERENTRIES.LIST"
)
for entry in ledger_entries:
account = {"account": encode_company_abbr(entry.LEDGERNAME.string.strip(), self.erpnext_company), "cost_center": self.default_cost_center}
account = {
"account": encode_company_abbr(entry.LEDGERNAME.string.strip(), self.erpnext_company),
"cost_center": self.default_cost_center,
}
if entry.ISPARTYLEDGER.string.strip() == "Yes":
party_details = get_party(entry.LEDGERNAME.string.strip())
if party_details:
@@ -438,7 +483,12 @@ class TallyMigration(Document):
return invoice
def get_voucher_items(voucher, doctype):
inventory_entries = voucher.find_all("INVENTORYENTRIES.LIST") + voucher.find_all("ALLINVENTORYENTRIES.LIST") + voucher.find_all("INVENTORYENTRIESIN.LIST") + voucher.find_all("INVENTORYENTRIESOUT.LIST")
inventory_entries = (
voucher.find_all("INVENTORYENTRIES.LIST")
+ voucher.find_all("ALLINVENTORYENTRIES.LIST")
+ voucher.find_all("INVENTORYENTRIESIN.LIST")
+ voucher.find_all("INVENTORYENTRIESOUT.LIST")
)
if doctype == "Sales Invoice":
account_field = "income_account"
elif doctype == "Purchase Invoice":
@@ -446,32 +496,41 @@ class TallyMigration(Document):
items = []
for entry in inventory_entries:
qty, uom = entry.ACTUALQTY.string.strip().split()
items.append({
"item_code": entry.STOCKITEMNAME.string.strip(),
"description": entry.STOCKITEMNAME.string.strip(),
"qty": qty.strip(),
"uom": uom.strip(),
"conversion_factor": 1,
"price_list_rate": entry.RATE.string.strip().split("/")[0],
"cost_center": self.default_cost_center,
"warehouse": self.default_warehouse,
account_field: encode_company_abbr(entry.find_all("ACCOUNTINGALLOCATIONS.LIST")[0].LEDGERNAME.string.strip(), self.erpnext_company),
})
items.append(
{
"item_code": entry.STOCKITEMNAME.string.strip(),
"description": entry.STOCKITEMNAME.string.strip(),
"qty": qty.strip(),
"uom": uom.strip(),
"conversion_factor": 1,
"price_list_rate": entry.RATE.string.strip().split("/")[0],
"cost_center": self.default_cost_center,
"warehouse": self.default_warehouse,
account_field: encode_company_abbr(
entry.find_all("ACCOUNTINGALLOCATIONS.LIST")[0].LEDGERNAME.string.strip(),
self.erpnext_company,
),
}
)
return items
def get_voucher_taxes(voucher):
ledger_entries = voucher.find_all("ALLLEDGERENTRIES.LIST") + voucher.find_all("LEDGERENTRIES.LIST")
ledger_entries = voucher.find_all("ALLLEDGERENTRIES.LIST") + voucher.find_all(
"LEDGERENTRIES.LIST"
)
taxes = []
for entry in ledger_entries:
if entry.ISPARTYLEDGER.string.strip() == "No":
tax_account = encode_company_abbr(entry.LEDGERNAME.string.strip(), self.erpnext_company)
taxes.append({
"charge_type": "Actual",
"account_head": tax_account,
"description": tax_account,
"tax_amount": entry.AMOUNT.string.strip(),
"cost_center": self.default_cost_center,
})
taxes.append(
{
"charge_type": "Actual",
"account_head": tax_account,
"description": tax_account,
"tax_amount": entry.AMOUNT.string.strip(),
"cost_center": self.default_cost_center,
}
)
return taxes
def get_party(party):
@@ -502,8 +561,11 @@ class TallyMigration(Document):
def _import_day_book_data(self):
def create_fiscal_years(vouchers):
from frappe.utils.data import add_years, getdate
earliest_date = getdate(min(voucher["posting_date"] for voucher in vouchers))
oldest_year = frappe.get_all("Fiscal Year", fields=["year_start_date", "year_end_date"], order_by="year_start_date")[0]
oldest_year = frappe.get_all(
"Fiscal Year", fields=["year_start_date", "year_end_date"], order_by="year_start_date"
)[0]
while earliest_date < oldest_year.year_start_date:
new_year = frappe.get_doc({"doctype": "Fiscal Year"})
new_year.year_start_date = add_years(oldest_year.year_start_date, -1)
@@ -520,32 +582,46 @@ class TallyMigration(Document):
"fieldtype": "Data",
"fieldname": "tally_guid",
"read_only": 1,
"label": "Tally GUID"
"label": "Tally GUID",
}
tally_voucher_no_df = {
"fieldtype": "Data",
"fieldname": "tally_voucher_no",
"read_only": 1,
"label": "Tally Voucher Number"
"label": "Tally Voucher Number",
}
for df in [tally_guid_df, tally_voucher_no_df]:
for doctype in doctypes:
create_custom_field(doctype, df)
def create_price_list():
frappe.get_doc({
"doctype": "Price List",
"price_list_name": "Tally Price List",
"selling": 1,
"buying": 1,
"enabled": 1,
"currency": "INR"
}).insert()
frappe.get_doc(
{
"doctype": "Price List",
"price_list_name": "Tally Price List",
"selling": 1,
"buying": 1,
"enabled": 1,
"currency": "INR",
}
).insert()
try:
frappe.db.set_value("Account", encode_company_abbr(self.tally_creditors_account, self.erpnext_company), "account_type", "Payable")
frappe.db.set_value("Account", encode_company_abbr(self.tally_debtors_account, self.erpnext_company), "account_type", "Receivable")
frappe.db.set_value("Company", self.erpnext_company, "round_off_account", self.default_round_off_account)
frappe.db.set_value(
"Account",
encode_company_abbr(self.tally_creditors_account, self.erpnext_company),
"account_type",
"Payable",
)
frappe.db.set_value(
"Account",
encode_company_abbr(self.tally_debtors_account, self.erpnext_company),
"account_type",
"Receivable",
)
frappe.db.set_value(
"Company", self.erpnext_company, "round_off_account", self.default_round_off_account
)
vouchers_file = frappe.get_doc("File", {"file_url": self.vouchers})
vouchers = json.loads(vouchers_file.get_content())
@@ -560,7 +636,16 @@ class TallyMigration(Document):
for index in range(0, total, VOUCHER_CHUNK_SIZE):
if index + VOUCHER_CHUNK_SIZE >= total:
is_last = True
frappe.enqueue_doc(self.doctype, self.name, "_import_vouchers", queue="long", timeout=3600, start=index+1, total=total, is_last=is_last)
frappe.enqueue_doc(
self.doctype,
self.name,
"_import_vouchers",
queue="long",
timeout=3600,
start=index + 1,
total=total,
is_last=is_last,
)
except Exception:
self.log()
@@ -572,7 +657,7 @@ class TallyMigration(Document):
frappe.flags.in_migrate = True
vouchers_file = frappe.get_doc("File", {"file_url": self.vouchers})
vouchers = json.loads(vouchers_file.get_content())
chunk = vouchers[start: start + VOUCHER_CHUNK_SIZE]
chunk = vouchers[start : start + VOUCHER_CHUNK_SIZE]
for index, voucher in enumerate(chunk, start=start):
try:
@@ -617,17 +702,22 @@ class TallyMigration(Document):
if sys.exc_info()[1].__class__ != frappe.DuplicateEntryError:
failed_import_log = json.loads(self.failed_import_log)
doc = data.as_dict()
failed_import_log.append({
"doc": doc,
"exc": traceback.format_exc()
})
self.failed_import_log = json.dumps(failed_import_log, separators=(',', ':'))
failed_import_log.append({"doc": doc, "exc": traceback.format_exc()})
self.failed_import_log = json.dumps(failed_import_log, separators=(",", ":"))
self.save()
frappe.db.commit()
else:
data = data or self.status
message = "\n".join(["Data:", json.dumps(data, default=str, indent=4), "--" * 50, "\nException:", traceback.format_exc()])
message = "\n".join(
[
"Data:",
json.dumps(data, default=str, indent=4),
"--" * 50,
"\nException:",
traceback.format_exc(),
]
)
return frappe.log_error(title="Tally Migration Error", message=message)
def set_status(self, status=""):

View File

@@ -14,27 +14,31 @@ from erpnext.erpnext_integrations.taxjar_integration import get_client
class TaxJarSettings(Document):
def on_update(self):
TAXJAR_CREATE_TRANSACTIONS = self.taxjar_create_transactions
TAXJAR_CALCULATE_TAX = self.taxjar_calculate_tax
TAXJAR_SANDBOX_MODE = self.is_sandbox
fields_already_exist = frappe.db.exists('Custom Field', {'dt': ('in', ['Item','Sales Invoice Item']), 'fieldname':'product_tax_category'})
fields_hidden = frappe.get_value('Custom Field', {'dt': ('in', ['Sales Invoice Item'])}, 'hidden')
fields_already_exist = frappe.db.exists(
"Custom Field",
{"dt": ("in", ["Item", "Sales Invoice Item"]), "fieldname": "product_tax_category"},
)
fields_hidden = frappe.get_value(
"Custom Field", {"dt": ("in", ["Sales Invoice Item"])}, "hidden"
)
if (TAXJAR_CREATE_TRANSACTIONS or TAXJAR_CALCULATE_TAX or TAXJAR_SANDBOX_MODE):
if TAXJAR_CREATE_TRANSACTIONS or TAXJAR_CALCULATE_TAX or TAXJAR_SANDBOX_MODE:
if not fields_already_exist:
add_product_tax_categories()
make_custom_fields()
add_permissions()
frappe.enqueue('erpnext.regional.united_states.setup.add_product_tax_categories', now=False)
frappe.enqueue("erpnext.regional.united_states.setup.add_product_tax_categories", now=False)
elif fields_already_exist and fields_hidden:
toggle_tax_category_fields(hidden='0')
toggle_tax_category_fields(hidden="0")
elif fields_already_exist:
toggle_tax_category_fields(hidden='1')
toggle_tax_category_fields(hidden="1")
def validate(self):
self.calculate_taxes_validation_for_create_transactions()
@@ -46,54 +50,97 @@ class TaxJarSettings(Document):
new_nexus_list = [frappe._dict(address) for address in nexus]
self.set('nexus', [])
self.set('nexus', new_nexus_list)
self.set("nexus", [])
self.set("nexus", new_nexus_list)
self.save()
def calculate_taxes_validation_for_create_transactions(self):
if not self.taxjar_calculate_tax and (self.taxjar_create_transactions or self.is_sandbox):
frappe.throw(frappe._('Before enabling <b>Create Transaction</b> or <b>Sandbox Mode</b>, you need to check the <b>Enable Tax Calculation</b> box'))
frappe.throw(
frappe._(
"Before enabling <b>Create Transaction</b> or <b>Sandbox Mode</b>, you need to check the <b>Enable Tax Calculation</b> box"
)
)
def toggle_tax_category_fields(hidden):
frappe.set_value('Custom Field', {'dt':'Sales Invoice Item', 'fieldname':'product_tax_category'}, 'hidden', hidden)
frappe.set_value('Custom Field', {'dt':'Item', 'fieldname':'product_tax_category'}, 'hidden', hidden)
frappe.set_value(
"Custom Field",
{"dt": "Sales Invoice Item", "fieldname": "product_tax_category"},
"hidden",
hidden,
)
frappe.set_value(
"Custom Field", {"dt": "Item", "fieldname": "product_tax_category"}, "hidden", hidden
)
def add_product_tax_categories():
with open(os.path.join(os.path.dirname(__file__), 'product_tax_category_data.json'), 'r') as f:
with open(os.path.join(os.path.dirname(__file__), "product_tax_category_data.json"), "r") as f:
tax_categories = json.loads(f.read())
create_tax_categories(tax_categories['categories'])
create_tax_categories(tax_categories["categories"])
def create_tax_categories(data):
for d in data:
if not frappe.db.exists('Product Tax Category',{'product_tax_code':d.get('product_tax_code')}):
tax_category = frappe.new_doc('Product Tax Category')
if not frappe.db.exists("Product Tax Category", {"product_tax_code": d.get("product_tax_code")}):
tax_category = frappe.new_doc("Product Tax Category")
tax_category.description = d.get("description")
tax_category.product_tax_code = d.get("product_tax_code")
tax_category.category_name = d.get("name")
tax_category.db_insert()
def make_custom_fields(update=True):
custom_fields = {
'Sales Invoice Item': [
dict(fieldname='product_tax_category', fieldtype='Link', insert_after='description', options='Product Tax Category',
label='Product Tax Category', fetch_from='item_code.product_tax_category'),
dict(fieldname='tax_collectable', fieldtype='Currency', insert_after='net_amount',
label='Tax Collectable', read_only=1, options='currency'),
dict(fieldname='taxable_amount', fieldtype='Currency', insert_after='tax_collectable',
label='Taxable Amount', read_only=1, options='currency')
"Sales Invoice Item": [
dict(
fieldname="product_tax_category",
fieldtype="Link",
insert_after="description",
options="Product Tax Category",
label="Product Tax Category",
fetch_from="item_code.product_tax_category",
),
dict(
fieldname="tax_collectable",
fieldtype="Currency",
insert_after="net_amount",
label="Tax Collectable",
read_only=1,
options="currency",
),
dict(
fieldname="taxable_amount",
fieldtype="Currency",
insert_after="tax_collectable",
label="Taxable Amount",
read_only=1,
options="currency",
),
],
"Item": [
dict(
fieldname="product_tax_category",
fieldtype="Link",
insert_after="item_group",
options="Product Tax Category",
label="Product Tax Category",
)
],
'Item': [
dict(fieldname='product_tax_category', fieldtype='Link', insert_after='item_group', options='Product Tax Category',
label='Product Tax Category')
]
}
create_custom_fields(custom_fields, update=update)
def add_permissions():
doctype = "Product Tax Category"
for role in ('Accounts Manager', 'Accounts User', 'System Manager','Item Manager', 'Stock Manager'):
for role in (
"Accounts Manager",
"Accounts User",
"System Manager",
"Item Manager",
"Stock Manager",
):
add_permission(doctype, role, 0)
update_permission_property(doctype, role, 0, 'write', 1)
update_permission_property(doctype, role, 0, 'create', 1)
update_permission_property(doctype, role, 0, "write", 1)
update_permission_property(doctype, role, 0, "create", 1)

View File

@@ -22,11 +22,23 @@ class WoocommerceSettings(Document):
custom_fields = {}
# create
for doctype in ["Customer", "Sales Order", "Item", "Address"]:
df = dict(fieldname='woocommerce_id', label='Woocommerce ID', fieldtype='Data', read_only=1, print_hide=1)
df = dict(
fieldname="woocommerce_id",
label="Woocommerce ID",
fieldtype="Data",
read_only=1,
print_hide=1,
)
create_custom_field(doctype, df)
for doctype in ["Customer", "Address"]:
df = dict(fieldname='woocommerce_email', label='Woocommerce Email', fieldtype='Data', read_only=1, print_hide=1)
df = dict(
fieldname="woocommerce_email",
label="Woocommerce Email",
fieldtype="Data",
read_only=1,
print_hide=1,
)
create_custom_field(doctype, df)
if not frappe.get_value("Item Group", {"name": _("WooCommerce Products")}):
@@ -58,21 +70,21 @@ class WoocommerceSettings(Document):
# for CI Test to work
url = "http://localhost:8000"
server_url = '{uri.scheme}://{uri.netloc}'.format(
uri=urlparse(url)
)
server_url = "{uri.scheme}://{uri.netloc}".format(uri=urlparse(url))
delivery_url = server_url + endpoint
self.endpoint = delivery_url
@frappe.whitelist()
def generate_secret():
woocommerce_settings = frappe.get_doc("Woocommerce Settings")
woocommerce_settings.secret = frappe.generate_hash()
woocommerce_settings.save()
@frappe.whitelist()
def get_series():
return {
"sales_order_series" : frappe.get_meta("Sales Order").get_options("naming_series") or "SO-WOO-",
"sales_order_series": frappe.get_meta("Sales Order").get_options("naming_series") or "SO-WOO-",
}

View File

@@ -6,15 +6,17 @@ from frappe import _
# api/method/erpnext.erpnext_integrations.exotel_integration.handle_end_call
# api/method/erpnext.erpnext_integrations.exotel_integration.handle_missed_call
@frappe.whitelist(allow_guest=True)
def handle_incoming_call(**kwargs):
try:
exotel_settings = get_exotel_settings()
if not exotel_settings.enabled: return
if not exotel_settings.enabled:
return
call_payload = kwargs
status = call_payload.get('Status')
if status == 'free':
status = call_payload.get("Status")
if status == "free":
return
call_log = get_call_log(call_payload)
@@ -24,12 +26,14 @@ def handle_incoming_call(**kwargs):
update_call_log(call_payload, call_log=call_log)
except Exception as e:
frappe.db.rollback()
frappe.log_error(title=_('Error in Exotel incoming call'))
frappe.log_error(title=_("Error in Exotel incoming call"))
frappe.db.commit()
@frappe.whitelist(allow_guest=True)
def handle_end_call(**kwargs):
update_call_log(kwargs, 'Completed')
update_call_log(kwargs, "Completed")
@frappe.whitelist(allow_guest=True)
def handle_missed_call(**kwargs):
@@ -38,15 +42,16 @@ def handle_missed_call(**kwargs):
dial_call_status = kwargs.get("DialCallStatus")
if call_type == "incomplete" and dial_call_status == "no-answer":
status = 'No Answer'
status = "No Answer"
elif call_type == "client-hangup" and dial_call_status == "canceled":
status = 'Canceled'
status = "Canceled"
elif call_type == "incomplete" and dial_call_status == "failed":
status = 'Failed'
status = "Failed"
update_call_log(kwargs, status)
def update_call_log(call_payload, status='Ringing', call_log=None):
def update_call_log(call_payload, status="Ringing", call_log=None):
call_log = call_log or get_call_log(call_payload)
# for a new sid, call_log and get_call_log will be empty so create a new log
@@ -54,72 +59,82 @@ def update_call_log(call_payload, status='Ringing', call_log=None):
call_log = create_call_log(call_payload)
if call_log:
call_log.status = status
call_log.to = call_payload.get('DialWhomNumber')
call_log.duration = call_payload.get('DialCallDuration') or 0
call_log.recording_url = call_payload.get('RecordingUrl')
call_log.to = call_payload.get("DialWhomNumber")
call_log.duration = call_payload.get("DialCallDuration") or 0
call_log.recording_url = call_payload.get("RecordingUrl")
call_log.save(ignore_permissions=True)
frappe.db.commit()
return call_log
def get_call_log(call_payload):
call_log = frappe.get_all('Call Log', {
'id': call_payload.get('CallSid'),
}, limit=1)
call_log = frappe.get_all(
"Call Log",
{
"id": call_payload.get("CallSid"),
},
limit=1,
)
if call_log:
return frappe.get_doc('Call Log', call_log[0].name)
return frappe.get_doc("Call Log", call_log[0].name)
def create_call_log(call_payload):
call_log = frappe.new_doc('Call Log')
call_log.id = call_payload.get('CallSid')
call_log.to = call_payload.get('DialWhomNumber')
call_log.medium = call_payload.get('To')
call_log.status = 'Ringing'
setattr(call_log, 'from', call_payload.get('CallFrom'))
call_log = frappe.new_doc("Call Log")
call_log.id = call_payload.get("CallSid")
call_log.to = call_payload.get("DialWhomNumber")
call_log.medium = call_payload.get("To")
call_log.status = "Ringing"
setattr(call_log, "from", call_payload.get("CallFrom"))
call_log.save(ignore_permissions=True)
frappe.db.commit()
return call_log
@frappe.whitelist()
def get_call_status(call_id):
endpoint = get_exotel_endpoint('Calls/{call_id}.json'.format(call_id=call_id))
endpoint = get_exotel_endpoint("Calls/{call_id}.json".format(call_id=call_id))
response = requests.get(endpoint)
status = response.json().get('Call', {}).get('Status')
status = response.json().get("Call", {}).get("Status")
return status
@frappe.whitelist()
def make_a_call(from_number, to_number, caller_id):
endpoint = get_exotel_endpoint('Calls/connect.json?details=true')
response = requests.post(endpoint, data={
'From': from_number,
'To': to_number,
'CallerId': caller_id
})
endpoint = get_exotel_endpoint("Calls/connect.json?details=true")
response = requests.post(
endpoint, data={"From": from_number, "To": to_number, "CallerId": caller_id}
)
return response.json()
def get_exotel_settings():
return frappe.get_single('Exotel Settings')
return frappe.get_single("Exotel Settings")
def whitelist_numbers(numbers, caller_id):
endpoint = get_exotel_endpoint('CustomerWhitelist')
response = requests.post(endpoint, data={
'VirtualNumber': caller_id,
'Number': numbers,
})
endpoint = get_exotel_endpoint("CustomerWhitelist")
response = requests.post(
endpoint,
data={
"VirtualNumber": caller_id,
"Number": numbers,
},
)
return response
def get_all_exophones():
endpoint = get_exotel_endpoint('IncomingPhoneNumbers')
endpoint = get_exotel_endpoint("IncomingPhoneNumbers")
response = requests.post(endpoint)
return response
def get_exotel_endpoint(action):
settings = get_exotel_settings()
return 'https://{api_key}:{api_token}@api.exotel.com/v1/Accounts/{sid}/{action}'.format(
api_key=settings.api_key,
api_token=settings.api_token,
sid=settings.account_sid,
action=action
return "https://{api_key}:{api_token}@api.exotel.com/v1/Accounts/{sid}/{action}".format(
api_key=settings.api_key, api_token=settings.api_token, sid=settings.account_sid, action=action
)

View File

@@ -16,17 +16,21 @@ def create_stripe_subscription(gateway_controller, data):
try:
stripe_settings.integration_request = create_request_log(stripe_settings.data, "Host", "Stripe")
stripe_settings.payment_plans = frappe.get_doc("Payment Request", stripe_settings.data.reference_docname).subscription_plans
stripe_settings.payment_plans = frappe.get_doc(
"Payment Request", stripe_settings.data.reference_docname
).subscription_plans
return create_subscription_on_stripe(stripe_settings)
except Exception:
frappe.log_error(frappe.get_traceback())
return{
return {
"redirect_to": frappe.redirect_to_message(
_('Server Error'),
_("It seems that there is an issue with the server's stripe configuration. In case of failure, the amount will get refunded to your account.")
_("Server Error"),
_(
"It seems that there is an issue with the server's stripe configuration. In case of failure, the amount will get refunded to your account."
),
),
"status": 401
"status": 401,
}
@@ -40,20 +44,20 @@ def create_subscription_on_stripe(stripe_settings):
customer = stripe.Customer.create(
source=stripe_settings.data.stripe_token_id,
description=stripe_settings.data.payer_name,
email=stripe_settings.data.payer_email
email=stripe_settings.data.payer_email,
)
subscription = stripe.Subscription.create(customer=customer, items=items)
if subscription.status == "active":
stripe_settings.integration_request.db_set('status', 'Completed', update_modified=False)
stripe_settings.integration_request.db_set("status", "Completed", update_modified=False)
stripe_settings.flags.status_changed_to = "Completed"
else:
stripe_settings.integration_request.db_set('status', 'Failed', update_modified=False)
frappe.log_error('Subscription N°: ' + subscription.id, 'Stripe Payment not completed')
stripe_settings.integration_request.db_set("status", "Failed", update_modified=False)
frappe.log_error("Subscription N°: " + subscription.id, "Stripe Payment not completed")
except Exception:
stripe_settings.integration_request.db_set('status', 'Failed', update_modified=False)
stripe_settings.integration_request.db_set("status", "Failed", update_modified=False)
frappe.log_error(frappe.get_traceback())
return stripe_settings.finalize_request()

View File

@@ -8,18 +8,92 @@ from frappe.utils import cint, flt
from erpnext import get_default_company, get_region
TAX_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "tax_account_head")
SHIP_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "shipping_account_head")
TAXJAR_CREATE_TRANSACTIONS = frappe.db.get_single_value("TaxJar Settings", "taxjar_create_transactions")
TAXJAR_CALCULATE_TAX = frappe.db.get_single_value("TaxJar Settings", "taxjar_calculate_tax")
SUPPORTED_COUNTRY_CODES = ["AT", "AU", "BE", "BG", "CA", "CY", "CZ", "DE", "DK", "EE", "ES", "FI",
"FR", "GB", "GR", "HR", "HU", "IE", "IT", "LT", "LU", "LV", "MT", "NL", "PL", "PT", "RO",
"SE", "SI", "SK", "US"]
SUPPORTED_STATE_CODES = ['AL', 'AK', 'AZ', 'AR', 'CA', 'CO', 'CT', 'DE', 'DC', 'FL', 'GA', 'HI', 'ID', 'IL',
'IN', 'IA', 'KS', 'KY', 'LA', 'ME', 'MD', 'MA', 'MI', 'MN', 'MS', 'MO', 'MT', 'NE',
'NV', 'NH', 'NJ', 'NM', 'NY', 'NC', 'ND', 'OH', 'OK', 'OR', 'PA', 'RI', 'SC', 'SD',
'TN', 'TX', 'UT', 'VT', 'VA', 'WA', 'WV', 'WI', 'WY']
SUPPORTED_COUNTRY_CODES = [
"AT",
"AU",
"BE",
"BG",
"CA",
"CY",
"CZ",
"DE",
"DK",
"EE",
"ES",
"FI",
"FR",
"GB",
"GR",
"HR",
"HU",
"IE",
"IT",
"LT",
"LU",
"LV",
"MT",
"NL",
"PL",
"PT",
"RO",
"SE",
"SI",
"SK",
"US",
]
SUPPORTED_STATE_CODES = [
"AL",
"AK",
"AZ",
"AR",
"CA",
"CO",
"CT",
"DE",
"DC",
"FL",
"GA",
"HI",
"ID",
"IL",
"IN",
"IA",
"KS",
"KY",
"LA",
"ME",
"MD",
"MA",
"MI",
"MN",
"MS",
"MO",
"MT",
"NE",
"NV",
"NH",
"NJ",
"NM",
"NY",
"NC",
"ND",
"OH",
"OK",
"OR",
"PA",
"RI",
"SC",
"SD",
"TN",
"TX",
"UT",
"VT",
"VA",
"WA",
"WV",
"WI",
"WY",
]
def get_client():
@@ -34,13 +108,15 @@ def get_client():
if api_key and api_url:
client = taxjar.Client(api_key=api_key, api_url=api_url)
client.set_api_config('headers', {
'x-api-version': '2020-08-07'
})
client.set_api_config("headers", {"x-api-version": "2022-01-24"})
return client
def create_transaction(doc, method):
TAXJAR_CREATE_TRANSACTIONS = frappe.db.get_single_value(
"TaxJar Settings", "taxjar_create_transactions"
)
"""Create an order transaction in TaxJar"""
if not TAXJAR_CREATE_TRANSACTIONS:
@@ -51,6 +127,7 @@ def create_transaction(doc, method):
if not client:
return
TAX_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "tax_account_head")
sales_tax = sum([tax.tax_amount for tax in doc.taxes if tax.account_head == TAX_ACCOUNT_HEAD])
if not sales_tax:
@@ -61,10 +138,10 @@ def create_transaction(doc, method):
if not tax_dict:
return
tax_dict['transaction_id'] = doc.name
tax_dict['transaction_date'] = frappe.utils.today()
tax_dict['sales_tax'] = sales_tax
tax_dict['amount'] = doc.total + tax_dict['shipping']
tax_dict["transaction_id"] = doc.name
tax_dict["transaction_date"] = frappe.utils.today()
tax_dict["sales_tax"] = sales_tax
tax_dict["amount"] = doc.total + tax_dict["shipping"]
try:
if doc.is_return:
@@ -79,6 +156,9 @@ def create_transaction(doc, method):
def delete_transaction(doc, method):
"""Delete an existing TaxJar order transaction"""
TAXJAR_CREATE_TRANSACTIONS = frappe.db.get_single_value(
"TaxJar Settings", "taxjar_create_transactions"
)
if not TAXJAR_CREATE_TRANSACTIONS:
return
@@ -92,6 +172,8 @@ def delete_transaction(doc, method):
def get_tax_data(doc):
SHIP_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "shipping_account_head")
from_address = get_company_address_details(doc)
from_shipping_state = from_address.get("state")
from_country_code = frappe.db.get_value("Country", from_address.country, "code")
@@ -107,29 +189,30 @@ def get_tax_data(doc):
line_items = [get_line_item_dict(item, doc.docstatus) for item in doc.items]
if from_shipping_state not in SUPPORTED_STATE_CODES:
from_shipping_state = get_state_code(from_address, 'Company')
from_shipping_state = get_state_code(from_address, "Company")
if to_shipping_state not in SUPPORTED_STATE_CODES:
to_shipping_state = get_state_code(to_address, 'Shipping')
to_shipping_state = get_state_code(to_address, "Shipping")
tax_dict = {
'from_country': from_country_code,
'from_zip': from_address.pincode,
'from_state': from_shipping_state,
'from_city': from_address.city,
'from_street': from_address.address_line1,
'to_country': to_country_code,
'to_zip': to_address.pincode,
'to_city': to_address.city,
'to_street': to_address.address_line1,
'to_state': to_shipping_state,
'shipping': shipping,
'amount': doc.net_total,
'plugin': 'erpnext',
'line_items': line_items
"from_country": from_country_code,
"from_zip": from_address.pincode,
"from_state": from_shipping_state,
"from_city": from_address.city,
"from_street": from_address.address_line1,
"to_country": to_country_code,
"to_zip": to_address.pincode,
"to_city": to_address.city,
"to_street": to_address.address_line1,
"to_state": to_shipping_state,
"shipping": shipping,
"amount": doc.net_total,
"plugin": "erpnext",
"line_items": line_items,
}
return tax_dict
def get_state_code(address, location):
if address is not None:
state_code = get_iso_3166_2_state_code(address)
@@ -140,26 +223,29 @@ def get_state_code(address, location):
return state_code
def get_line_item_dict(item, docstatus):
tax_dict = dict(
id = item.get('idx'),
quantity = item.get('qty'),
unit_price = item.get('rate'),
product_tax_code = item.get('product_tax_category')
id=item.get("idx"),
quantity=item.get("qty"),
unit_price=item.get("rate"),
product_tax_code=item.get("product_tax_category"),
)
if docstatus == 1:
tax_dict.update({
'sales_tax':item.get('tax_collectable')
})
tax_dict.update({"sales_tax": item.get("tax_collectable")})
return tax_dict
def set_sales_tax(doc, method):
TAX_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "tax_account_head")
TAXJAR_CALCULATE_TAX = frappe.db.get_single_value("TaxJar Settings", "taxjar_calculate_tax")
if not TAXJAR_CALCULATE_TAX:
return
if get_region(doc.company) != 'United States':
if get_region(doc.company) != "United States":
return
if not doc.items:
@@ -192,21 +278,26 @@ def set_sales_tax(doc, method):
doc.run_method("calculate_taxes_and_totals")
break
else:
doc.append("taxes", {
"charge_type": "Actual",
"description": "Sales Tax",
"account_head": TAX_ACCOUNT_HEAD,
"tax_amount": tax_data.amount_to_collect
})
doc.append(
"taxes",
{
"charge_type": "Actual",
"description": "Sales Tax",
"account_head": TAX_ACCOUNT_HEAD,
"tax_amount": tax_data.amount_to_collect,
},
)
# Assigning values to tax_collectable and taxable_amount fields in sales item table
for item in tax_data.breakdown.line_items:
doc.get('items')[cint(item.id)-1].tax_collectable = item.tax_collectable
doc.get('items')[cint(item.id)-1].taxable_amount = item.taxable_amount
doc.get("items")[cint(item.id) - 1].tax_collectable = item.tax_collectable
doc.get("items")[cint(item.id) - 1].taxable_amount = item.taxable_amount
doc.run_method("calculate_taxes_and_totals")
def check_for_nexus(doc, tax_dict):
if not frappe.db.get_value('TaxJar Nexus', {'region_code': tax_dict["to_state"]}):
TAX_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "tax_account_head")
if not frappe.db.get_value("TaxJar Nexus", {"region_code": tax_dict["to_state"]}):
for item in doc.get("items"):
item.tax_collectable = flt(0)
item.taxable_amount = flt(0)
@@ -216,11 +307,17 @@ def check_for_nexus(doc, tax_dict):
doc.taxes.remove(tax)
return
def check_sales_tax_exemption(doc):
# if the party is exempt from sales tax, then set all tax account heads to zero
sales_tax_exempted = hasattr(doc, "exempt_from_sales_tax") and doc.exempt_from_sales_tax \
or frappe.db.has_column("Customer", "exempt_from_sales_tax") \
TAX_ACCOUNT_HEAD = frappe.db.get_single_value("TaxJar Settings", "tax_account_head")
sales_tax_exempted = (
hasattr(doc, "exempt_from_sales_tax")
and doc.exempt_from_sales_tax
or frappe.db.has_column("Customer", "exempt_from_sales_tax")
and frappe.db.get_value("Customer", doc.customer, "exempt_from_sales_tax")
)
if sales_tax_exempted:
for tax in doc.taxes:
@@ -232,6 +329,7 @@ def check_sales_tax_exemption(doc):
else:
return False
def validate_tax_request(tax_dict):
"""Return the sales tax that should be collected for a given order."""
@@ -275,9 +373,12 @@ def get_shipping_address_details(doc):
def get_iso_3166_2_state_code(address):
import pycountry
country_code = frappe.db.get_value("Country", address.get("country"), "code")
error_message = _("""{0} is not a valid state! Check for typos or enter the ISO code for your state.""").format(address.get("state"))
error_message = _(
"""{0} is not a valid state! Check for typos or enter the ISO code for your state."""
).format(address.get("state"))
state = address.get("state").upper().strip()
# The max length for ISO state codes is 3, excluding the country code
@@ -298,7 +399,7 @@ def get_iso_3166_2_state_code(address):
except LookupError:
frappe.throw(_(error_message))
else:
return lookup_state.code.split('-')[1]
return lookup_state.code.split("-")[1]
def sanitize_error_response(response):
@@ -309,7 +410,7 @@ def sanitize_error_response(response):
"to zip": "Zipcode",
"to city": "City",
"to state": "State",
"to country": "Country"
"to country": "Country",
}
for k, v in sanitized_responses.items():

View File

@@ -9,28 +9,24 @@ from frappe import _
from erpnext import get_default_company
def validate_webhooks_request(doctype, hmac_key, secret_key='secret'):
def validate_webhooks_request(doctype, hmac_key, secret_key="secret"):
def innerfn(fn):
settings = frappe.get_doc(doctype)
if frappe.request and settings and settings.get(secret_key) and not frappe.flags.in_test:
sig = base64.b64encode(
hmac.new(
settings.get(secret_key).encode('utf8'),
frappe.request.data,
hashlib.sha256
).digest()
hmac.new(settings.get(secret_key).encode("utf8"), frappe.request.data, hashlib.sha256).digest()
)
if frappe.request.data and \
not sig == bytes(frappe.get_request_header(hmac_key).encode()):
frappe.throw(_("Unverified Webhook Data"))
if frappe.request.data and not sig == bytes(frappe.get_request_header(hmac_key).encode()):
frappe.throw(_("Unverified Webhook Data"))
frappe.set_user(settings.modified_by)
return fn
return innerfn
def get_webhook_address(connector_name, method, exclude_uri=False, force_https=False):
endpoint = "erpnext.erpnext_integrations.connectors.{0}.{1}".format(connector_name, method)
@@ -50,34 +46,40 @@ def get_webhook_address(connector_name, method, exclude_uri=False, force_https=F
return server_url
def create_mode_of_payment(gateway, payment_type="General"):
payment_gateway_account = frappe.db.get_value("Payment Gateway Account", {
"payment_gateway": gateway
}, ['payment_account'])
payment_gateway_account = frappe.db.get_value(
"Payment Gateway Account", {"payment_gateway": gateway}, ["payment_account"]
)
mode_of_payment = frappe.db.exists("Mode of Payment", gateway)
if not mode_of_payment and payment_gateway_account:
mode_of_payment = frappe.get_doc({
"doctype": "Mode of Payment",
"mode_of_payment": gateway,
"enabled": 1,
"type": payment_type,
"accounts": [{
"doctype": "Mode of Payment Account",
"company": get_default_company(),
"default_account": payment_gateway_account
}]
})
mode_of_payment = frappe.get_doc(
{
"doctype": "Mode of Payment",
"mode_of_payment": gateway,
"enabled": 1,
"type": payment_type,
"accounts": [
{
"doctype": "Mode of Payment Account",
"company": get_default_company(),
"default_account": payment_gateway_account,
}
],
}
)
mode_of_payment.insert(ignore_permissions=True)
return mode_of_payment
elif mode_of_payment:
return frappe.get_doc("Mode of Payment", mode_of_payment)
def get_tracking_url(carrier, tracking_number):
# Return the formatted Tracking URL.
tracking_url = ''
url_reference = frappe.get_value('Parcel Service', carrier, 'url_reference')
tracking_url = ""
url_reference = frappe.get_value("Parcel Service", carrier, "url_reference")
if url_reference:
tracking_url = frappe.render_template(url_reference, {'tracking_number': tracking_number})
tracking_url = frappe.render_template(url_reference, {"tracking_number": tracking_number})
return tracking_url