# Copyright (c) 2020, Jingrow Technologies Pvt. Ltd. and Contributors # For license information, please see license.txt from __future__ import annotations from itertools import groupby import jingrow from jingrow import _ # Import this for translation functionality from jingrow.core.utils import find from jingrow.utils import fmt_money, get_request_site_address from jcloude.api.regional_payments.mpesa.utils import ( create_invoice_partner_site, create_payment_partner_transaction, fetch_param_value, get_details_from_request_log, get_mpesa_setup_for_team, get_payment_gateway, sanitize_mobile_number, update_tax_id_or_phone_no, ) from jcloude.guards import role_guard from jcloude.jcloude.pagetype.mpesa_setup.mpesa_connector import MpesaConnector from jcloude.jcloude.pagetype.team.team import ( _enqueue_finalize_unpaid_invoices_for_team, has_unsettled_invoices, ) from jcloude.utils import get_current_team from jcloude.utils.billing import ( GSTIN_FORMAT, clear_setup_intent, get_publishable_key, get_razorpay_client, get_setup_intent, get_stripe, make_formatted_pg, states_with_tin, validate_gstin_check_digit, ) from jcloude.utils.mpesa_utils import create_mpesa_request_log # from jcloude.jcloude.pagetype.paymob_callback_log.paymob_callback_log import create_payment_partner_transaction @jingrow.whitelist() @role_guard.api("billing") def get_publishable_key_and_setup_intent(): team = get_current_team() return { "publishable_key": get_publishable_key(), "setup_intent": get_setup_intent(team), } @jingrow.whitelist() @role_guard.api("billing") def upcoming_invoice(): team = get_current_team(True) invoice = team.get_upcoming_invoice() if invoice: upcoming_invoice = invoice.as_dict() upcoming_invoice.formatted = make_formatted_pg(invoice, ["Currency"]) else: upcoming_invoice = None return { "upcoming_invoice": upcoming_invoice, "available_credits": fmt_money(team.get_balance(), 2, team.currency), } @jingrow.whitelist() @role_guard.api("billing") def get_balance_credit(): team = get_current_team(True) return team.get_balance() @jingrow.whitelist() @role_guard.api("billing") def past_invoices(): return get_current_team(True).get_past_invoices() @jingrow.whitelist() @role_guard.api("billing") def invoices_and_payments(): team = get_current_team(True) return team.get_past_invoices() @jingrow.whitelist() @role_guard.api("billing") def refresh_invoice_link(invoice): pg = jingrow.get_pg("Invoice", invoice) return pg.refresh_stripe_payment_link() @jingrow.whitelist() @role_guard.api("billing") def balances(): team = get_current_team() has_bought_credits = jingrow.db.get_all( "Balance Transaction", filters={ "source": ("in", ("Prepaid Credits", "Transferred Credits", "Free Credits")), "team": team, "docstatus": 1, "type": ("!=", "Partnership Fee"), }, limit=1, ) if not has_bought_credits: return [] bt = jingrow.qb.PageType("Balance Transaction") inv = jingrow.qb.PageType("Invoice") query = ( jingrow.qb.from_(bt) .left_join(inv) .on(bt.invoice == inv.name) .select( bt.name, bt.creation, bt.amount, bt.currency, bt.source, bt.type, bt.ending_balance, bt.description, inv.period_start, ) .where((bt.docstatus == 1) & (bt.team == team)) .orderby(bt.creation, order=jingrow.qb.desc) ) data = query.run(as_dict=True) for d in data: d.formatted = dict( amount=fmt_money(d.amount, 2, d.currency), ending_balance=fmt_money(d.ending_balance, 2, d.currency), ) if d.period_start: d.formatted["invoice_for"] = d.period_start.strftime("%B %Y") return data def get_processed_balance_transactions(transactions: list[dict]) -> list: """Cleans up transactions and adjusts ending balances accordingly""" cleaned_up_transations = get_cleaned_up_transactions(transactions) processed_balance_transactions: list[dict] = [] for bt in reversed(cleaned_up_transations): if is_added_credits_bt(bt) and len(processed_balance_transactions) < 1: processed_balance_transactions.append(bt) elif is_added_credits_bt(bt): bt["ending_balance"] = bt.get("ending_balance", 0) + processed_balance_transactions[-1].get( "ending_balance", 0 ) # Adjust the ending balance processed_balance_transactions.append(bt) elif bt.get("type") == "Applied To Invoice": processed_balance_transactions.append(bt) return list(reversed(processed_balance_transactions)) def get_cleaned_up_transactions(transactions: list[dict]): """Only picks Balance transactions that the users care about""" cleaned_up_transations = [] for bt in transactions: if is_added_credits_bt(bt): cleaned_up_transations.append(bt) continue if bt.get("type") == "Applied To Invoice" and not find( cleaned_up_transations, lambda x: x.get("invoice") == bt.get("invoice") ): cleaned_up_transations.append(bt) continue return cleaned_up_transations def is_added_credits_bt(bt): """Returns `true` if credits were added and not some reverse transaction""" if not ( bt.type == "Adjustment" and bt.source in ( "Prepaid Credits", "Free Credits", "Transferred Credits", ) # Might need to re-think this ): return False # Is not a reverse of a previous balance transaction bt.description = bt.description or "" return not bt.description.startswith("Reverse") @jingrow.whitelist() @role_guard.api("billing") def details(): team = get_current_team(True) address = None if team.billing_address: address = jingrow.get_pg("Address", team.billing_address) address_parts = [ address.address_line1, address.city, address.state, address.country, address.pincode, ] billing_address = ", ".join([d for d in address_parts if d]) else: billing_address = "" return { "billing_name": team.billing_name, "billing_address": billing_address, "gstin": address.gstin if address else None, } @jingrow.whitelist() @role_guard.api("billing") def fetch_invoice_items(invoice): team = get_current_team() if jingrow.db.get_value("Invoice", invoice, "team") != team: jingrow.throw("Only team owners and members are permitted to download Invoice") return jingrow.get_all( "Invoice Item", {"parent": invoice, "parenttype": "Invoice"}, [ "document_type", "document_name", "rate", "quantity", "amount", "plan", "description", "discount", "site", ], ) @jingrow.whitelist() @role_guard.api("billing") def get_customer_details(team): """This method is called by framework.jingrow.com for creating Customer and Address""" team_pg = jingrow.db.get_value("Team", team, "*") return { "team": team_pg, "address": jingrow.get_pg("Address", team_pg.billing_address), } @jingrow.whitelist() @role_guard.api("billing") def create_payment_intent_for_micro_debit(): team = get_current_team(True) stripe = get_stripe() micro_debit_charge_field = ( "micro_debit_charge_usd" if team.currency == "USD" else "micro_debit_charge_inr" ) amount = jingrow.db.get_single_value("Jcloude Settings", micro_debit_charge_field) intent = stripe.PaymentIntent.create( amount=int(amount * 100), currency=team.currency.lower(), customer=team.stripe_customer_id, description="Micro-Debit Card Test Charge", metadata={ "payment_for": "micro_debit_test_charge", }, ) return {"client_secret": intent["client_secret"]} @jingrow.whitelist() @role_guard.api("billing") def create_payment_intent_for_partnership_fees(): team = get_current_team(True) jcloude_settings = jingrow.get_cached_pg("Jcloude Settings") metadata = {"payment_for": "partnership_fee"} fee_amount = jcloude_settings.partnership_fee_usd if team.currency == "INR": fee_amount = jcloude_settings.partnership_fee_inr gst_amount = fee_amount * jcloude_settings.gst_percentage fee_amount += gst_amount metadata.update({"gst": round(gst_amount, 2)}) stripe = get_stripe() intent = stripe.PaymentIntent.create( amount=int(fee_amount * 100), currency=team.currency.lower(), customer=team.stripe_customer_id, description="Partnership Fee", metadata=metadata, ) return { "client_secret": intent["client_secret"], "publishable_key": get_publishable_key(), } @jingrow.whitelist() @role_guard.api("billing") def create_payment_intent_for_buying_credits(amount): team = get_current_team(True) metadata = {"payment_for": "prepaid_credits"} total_unpaid = total_unpaid_amount() if amount < total_unpaid and not team.erpnext_partner: jingrow.throw(f"Amount {amount} is less than the total unpaid amount {total_unpaid}.") if team.currency == "INR": gst_amount = amount * jingrow.db.get_single_value("Jcloude Settings", "gst_percentage") amount += gst_amount metadata.update({"gst": round(gst_amount, 2)}) amount = round(amount, 2) stripe = get_stripe() intent = stripe.PaymentIntent.create( amount=int(amount * 100), currency=team.currency.lower(), customer=team.stripe_customer_id, description="Prepaid Credits", metadata=metadata, ) return { "client_secret": intent["client_secret"], "publishable_key": get_publishable_key(), } @jingrow.whitelist() @role_guard.api("billing") def create_payment_intent_for_prepaid_app(amount, metadata): stripe = get_stripe() team = get_current_team(True) payment_method = jingrow.get_value( "Stripe Payment Method", team.default_payment_method, "stripe_payment_method_id" ) try: if not payment_method: intent = stripe.PaymentIntent.create( amount=amount * 100, currency=team.currency.lower(), customer=team.stripe_customer_id, description="Prepaid App Purchase", metadata=metadata, ) else: intent = stripe.PaymentIntent.create( amount=amount * 100, currency=team.currency.lower(), customer=team.stripe_customer_id, description="Prepaid App Purchase", off_session=True, confirm=True, metadata=metadata, payment_method=payment_method, payment_method_options={"card": {"request_three_d_secure": "any"}}, ) return { "payment_method": payment_method, "client_secret": intent["client_secret"], "publishable_key": get_publishable_key(), } except stripe.error.CardError as e: err = e.error if err.code == "authentication_required": # Bring the customer back on-session to authenticate the purchase return { "error": "authentication_required", "payment_method": err.payment_method.id, "amount": amount, "card": err.payment_method.card, "publishable_key": get_publishable_key(), "client_secret": err.payment_intent.client_secret, } if err.code: # The card was declined for other reasons (e.g. insufficient funds) # Bring the customer back on-session to ask them for a new payment method return { "error": err.code, "payment_method": err.payment_method.id, "publishable_key": get_publishable_key(), "client_secret": err.payment_intent.client_secret, } @jingrow.whitelist() @role_guard.api("billing") def get_payment_methods(): team = get_current_team() return jingrow.get_pg("Team", team).get_payment_methods() @jingrow.whitelist() @role_guard.api("billing") def set_as_default(name): payment_method = jingrow.get_pg("Stripe Payment Method", {"name": name, "team": get_current_team()}) payment_method.set_default() @jingrow.whitelist() @role_guard.api("billing") def remove_payment_method(name): team = get_current_team() payment_method_count = jingrow.db.count("Stripe Payment Method", {"team": team}) if has_unsettled_invoices(team) and payment_method_count == 1: return "Unpaid Invoices" payment_method = jingrow.get_pg("Stripe Payment Method", {"name": name, "team": team}) payment_method.delete() return None @jingrow.whitelist() @role_guard.api("billing") def finalize_invoices(): unsettled_invoices = jingrow.get_all( "Invoice", {"team": get_current_team(), "status": ("in", ("Draft", "Unpaid"))}, pluck="name", ) for inv in unsettled_invoices: inv_pg = jingrow.get_pg("Invoice", inv) inv_pg.finalize_invoice() @jingrow.whitelist() @role_guard.api("billing") def unpaid_invoices(): team = get_current_team() return jingrow.db.get_all( "Invoice", { "team": team, "status": ("in", ["Draft", "Unpaid", "Invoice Created"]), "type": "Subscription", }, ["name", "status", "period_end", "currency", "amount_due", "total"], order_by="creation asc", ) @jingrow.whitelist() @role_guard.api("billing") def get_unpaid_invoices(): team = get_current_team() unpaid_invoices = jingrow.db.get_all( "Invoice", { "team": team, "status": "Unpaid", "type": "Subscription", }, ["name", "status", "period_end", "currency", "amount_due", "total", "stripe_invoice_url"], order_by="creation asc", ) return unpaid_invoices # noqa: RET504 @jingrow.whitelist() @role_guard.api("billing") def change_payment_mode(mode): team = get_current_team(get_pg=True) team.payment_mode = mode if team.partner_email and mode == "Paid By Partner" and not team.billing_team: team.billing_team = jingrow.db.get_value( "Team", {"enabled": 1, "erpnext_partner": 1, "partner_email": team.partner_email}, "name", ) if team.billing_team and mode != "Paid By Partner": team.billing_team = "" team.save() return @jingrow.whitelist() @role_guard.api("billing") def prepaid_credits_via_onboarding(): """When prepaid credits are bought, the balance is not immediately reflected. This method will check balance every second and then set payment_mode""" from time import sleep team = get_current_team(get_pg=True) seconds = 0 # block until balance is updated while team.get_balance() == 0 or seconds > 20: seconds += 1 sleep(1) jingrow.db.rollback() team.payment_mode = "Prepaid Credits" team.save() @jingrow.whitelist() @role_guard.api("billing") def get_invoice_usage(invoice): team = get_current_team() # apply team filter for safety pg = jingrow.get_pg("Invoice", {"name": invoice, "team": team}) out = pg.as_dict() # a dict with formatted currency values for display out.formatted = make_formatted_pg(pg) out.invoice_pdf = pg.invoice_pdf or (pg.currency == "USD" and pg.get_pdf()) return out @jingrow.whitelist() @role_guard.api("billing") def get_summary(): team = get_current_team() invoices = jingrow.get_all( "Invoice", filters={"team": team, "status": ("in", ["Paid", "Unpaid"])}, fields=[ "name", "status", "period_end", "payment_mode", "type", "currency", "amount_paid", ], order_by="creation desc", ) invoice_names = [x.name for x in invoices] grouped_invoice_items = get_grouped_invoice_items(invoice_names) for invoice in invoices: invoice.items = grouped_invoice_items.get(invoice.name, []) return invoices def get_grouped_invoice_items(invoices: list[str]) -> dict: """Takes a list of invoices (invoice names) and returns a dict of the form: { "": [], "": [], } """ invoice_items = jingrow.get_all( "Invoice Item", filters={"parent": ("in", invoices)}, fields=[ "amount", "document_name AS name", "document_type AS type", "parent", "quantity", "rate", "plan", ], ) grouped_items = groupby(invoice_items, key=lambda x: x["parent"]) invoice_items_map = {} for invoice_name, items in grouped_items: invoice_items_map[invoice_name] = list(items) return invoice_items_map @jingrow.whitelist() @role_guard.api("billing") def after_card_add(): clear_setup_intent() @jingrow.whitelist() @role_guard.api("billing") def setup_intent_success(setup_intent, address=None): setup_intent = jingrow._dict(setup_intent) # refetching the setup intent to get mandate_id from stripe stripe = get_stripe() setup_intent = stripe.SetupIntent.retrieve(setup_intent.id) team = get_current_team(True) clear_setup_intent() mandate_reference = setup_intent.payment_method_options.card.mandate_options.reference payment_method = team.create_payment_method( setup_intent.payment_method, setup_intent.id, setup_intent.mandate, mandate_reference, set_default=True, verified_with_micro_charge=True, ) if address: address = jingrow._dict(address) team.update_billing_details(address) return {"payment_method_name": payment_method.name} @jingrow.whitelist() @role_guard.api("billing") def validate_gst(address, method=None): if isinstance(address, dict): address = jingrow._dict(address) if address.country != "India": return if address.state not in states_with_tin: jingrow.throw("Invalid State for India.") if not address.gstin: jingrow.throw("GSTIN is required for Indian customers.") if address.gstin and address.gstin != "Not Applicable": if not GSTIN_FORMAT.match(address.gstin): jingrow.throw("Invalid GSTIN. The input you've entered does not match the format of GSTIN.") tin_code = states_with_tin[address.state] if not address.gstin.startswith(tin_code): jingrow.throw(f"GSTIN must start with {tin_code} for {address.state}.") validate_gstin_check_digit(address.gstin) @jingrow.whitelist() @role_guard.api("billing") def get_latest_unpaid_invoice(): team = get_current_team() unpaid_invoices = jingrow.get_all( "Invoice", {"team": team, "status": "Unpaid", "payment_attempt_count": (">", 0)}, pluck="name", order_by="creation desc", limit=1, ) if unpaid_invoices: unpaid_invoice = jingrow.db.get_value( "Invoice", unpaid_invoices[0], ["amount_due", "payment_mode", "amount_due", "currency"], as_dict=True, ) if unpaid_invoice.payment_mode == "Prepaid Credits" and team_has_balance_for_invoice(unpaid_invoice): return None return unpaid_invoice return None def team_has_balance_for_invoice(prepaid_mode_invoice): team = get_current_team(get_pg=True) return team.get_balance() >= prepaid_mode_invoice.amount_due @jingrow.whitelist() @role_guard.api("billing") def is_paypal_enabled() -> bool: return jingrow.db.get_single_value("Jcloude Settings", "paypal_enabled") @jingrow.whitelist() @role_guard.api("billing") def create_razorpay_order(amount, transaction_type, pg_name=None) -> dict | None: if not transaction_type: jingrow.throw(_("Transaction type is not set")) if not amount or amount <= 0: jingrow.throw(_("Amount should be greater than zero")) team = get_current_team(get_pg=True) # transaction type validations _validate_razorpay_order_type(transaction_type, amount, pg_name, team.currency) # GST for INR transactions gst_amount = 0 if team.currency == "INR": gst_amount = amount * jingrow.db.get_single_value("Jcloude Settings", "gst_percentage") amount += gst_amount # normalize type for payment record payment_record_type = ( "Prepaid Credits" if transaction_type in ["Invoice", "Purchase Plan"] else transaction_type ) amount = round(amount, 2) data = { "amount": int(amount * 100), "currency": team.currency, "notes": { "Description": "Order for Jingrow Cloud Prepaid Credits", "Team (Jingrow Cloud ID)": team.name, "gst": gst_amount, "Type": payment_record_type, }, } client = get_razorpay_client() order = client.order.create(data=data) payment_record = jingrow.get_pg( { "pagetype": "Razorpay Payment Record", "order_id": order.get("id"), "team": team.name, "type": payment_record_type, } ).insert(ignore_permissions=True) return { "order_id": order.get("id"), "key_id": client.auth[0], "payment_record": payment_record.name, } def _validate_razorpay_order_type(transaction_type, amount, pg_name, currency): if transaction_type == "Prepaid Credits": _validate_prepaid_credits(amount, currency) elif transaction_type == "Purchase Plan": _validate_purchase_plan(amount, pg_name, currency) elif transaction_type == "Invoice": _validate_invoice_payment(amount, pg_name, currency) def _validate_prepaid_credits(amount, currency): minimum_amount = 100 if currency == "INR" else 5 if amount < minimum_amount: currency_symbol = "₹" if currency == "INR" else "$" jingrow.throw(_("Amount should be at least {0}{1}").format(currency_symbol, minimum_amount)) def _validate_purchase_plan(amount, pg_name, currency): if not pg_name or not jingrow.db.exists("Plan", pg_name): jingrow.throw(_("Plan {0} does not exist").format(pg_name or "")) price_field = "price_inr" if currency == "INR" else "price_usd" plan_amount = jingrow.db.get_value("Plan", pg_name, price_field) if amount < plan_amount: currency_symbol = "₹" if currency == "INR" else "$" jingrow.throw( _("Amount should not be less than plan amount of {0}{1}").format(currency_symbol, plan_amount) ) def _validate_invoice_payment(amount, pg_name, currency): if not pg_name or not jingrow.db.exists("Invoice", pg_name): jingrow.throw(_("Invoice {0} does not exist").format(pg_name or "")) invoice_amount = jingrow.db.get_value("Invoice", pg_name, "amount_due_with_tax") if amount < invoice_amount: currency_symbol = "₹" if currency == "INR" else "$" jingrow.throw( _("Amount should not be less than invoice amount of {0}{1}").format( currency_symbol, invoice_amount ) ) @jingrow.whitelist() @role_guard.api("billing") def handle_razorpay_payment_success(response): client = get_razorpay_client() client.utility.verify_payment_signature(response) payment_record = jingrow.get_pg( "Razorpay Payment Record", {"order_id": response.get("razorpay_order_id")}, for_update=True, ) payment_record.update( { "payment_id": response.get("razorpay_payment_id"), "signature": response.get("razorpay_signature"), "status": "Captured", } ) payment_record.save(ignore_permissions=True) @jingrow.whitelist() @role_guard.api("billing") def handle_razorpay_payment_failed(response): payment_record = jingrow.get_pg( "Razorpay Payment Record", {"order_id": response["error"]["metadata"].get("order_id")}, for_update=True, ) payment_record.status = "Failed" payment_record.failure_reason = response["error"]["description"] payment_record.save(ignore_permissions=True) @jingrow.whitelist() @role_guard.api("billing") def total_unpaid_amount(): team = get_current_team(get_pg=True) balance = team.get_balance() negative_balance = -1 * balance if balance < 0 else 0 return ( jingrow.get_all( "Invoice", {"status": "Unpaid", "team": team.name, "type": "Subscription", "docstatus": ("!=", 2)}, ["sum(amount_due) as total"], pluck="total", )[0] or 0 ) + negative_balance @jingrow.whitelist() @role_guard.api("billing") def get_current_billing_amount(): team = get_current_team(get_pg=True) due_date = jingrow.utils.get_last_day(jingrow.utils.getdate()) return ( jingrow.get_value( "Invoice", {"team": team.name, "due_date": due_date, "docstatus": 0}, "total", ) or 0 ) # Mpesa integrations, mpesa express """Send stk push to the user""" def generate_stk_push(**kwargs): """Generate stk push by making a API call to the stk push API.""" args = jingrow._dict(kwargs) partner_value = args.partner # Fetch the team document based on the extracted partner value partner = jingrow.get_all("Team", filters={"user": partner_value, "erpnext_partner": 1}, pluck="name") if not partner: jingrow.throw(_(f"Partner team {partner_value} not found"), title=_("Mpesa Express Error")) # Get Mpesa settings for the partner's team mpesa_setup = get_mpesa_setup_for_team(partner[0]) try: callback_url = ( get_request_site_address(True) + "/api/method/jcloude.api.billing.verify_m_pesa_transaction" ) env = "production" if not mpesa_setup.sandbox else "sandbox" # for sandbox, business shortcode is same as till number business_shortcode = ( mpesa_setup.business_shortcode if env == "production" else mpesa_setup.till_number ) connector = MpesaConnector( env=env, app_key=mpesa_setup.consumer_key, app_secret=mpesa_setup.get_password("consumer_secret"), ) mobile_number = sanitize_mobile_number(args.sender) response = connector.stk_push( business_shortcode=business_shortcode, amount=args.amount_with_tax, passcode=mpesa_setup.get_password("pass_key"), callback_url=callback_url, reference_code=mpesa_setup.till_number, phone_number=mobile_number, description="Jingrow Cloud Payment", ) return response # noqa: RET504 except Exception: jingrow.log_error("Mpesa Express Transaction Error") jingrow.throw( _("Issue detected with Mpesa configuration, check the error logs for more details"), title=_("Mpesa Express Error"), ) @jingrow.whitelist(allow_guest=True) def verify_m_pesa_transaction(**kwargs): """Verify the transaction result received via callback from STK.""" transaction_response, request_id = parse_transaction_response(kwargs) status = handle_transaction_result(transaction_response, request_id) return {"status": status, "ResultDesc": transaction_response.get("ResultDesc")} def parse_transaction_response(kwargs): """Parse and validate the transaction response.""" if "Body" not in kwargs or "stkCallback" not in kwargs["Body"]: jingrow.log_error(title="Invalid transaction response format", message=kwargs) jingrow.throw(_("Invalid transaction response format")) transaction_response = jingrow._dict(kwargs["Body"]["stkCallback"]) checkout_id = getattr(transaction_response, "CheckoutRequestID", "") if not isinstance(checkout_id, str): jingrow.throw(_("Invalid Checkout Request ID")) return transaction_response, checkout_id def handle_transaction_result(transaction_response, integration_request): """Handle the logic based on ResultCode in the transaction response.""" result_code = transaction_response.get("ResultCode") status = None if result_code == 0: try: status = "Completed" create_mpesa_request_log( transaction_response, "Host", "Mpesa Express", integration_request, None, status ) create_mpesa_payment_record(transaction_response) except Exception as e: jingrow.log_error(f"Mpesa: Transaction failed with error {e}") elif result_code == 1037: # User unreachable (Phone off or timeout) status = "Failed" create_mpesa_request_log( transaction_response, "Host", "Mpesa Express", integration_request, None, status ) jingrow.log_error("Mpesa: User cannot be reached (Phone off or timeout)") elif result_code == 1032: # User cancelled the request status = "Cancelled" create_mpesa_request_log( transaction_response, "Host", "Mpesa Express", integration_request, None, status ) jingrow.log_error("Mpesa: Request cancelled by user") else: # Other failure codes status = "Failed" create_mpesa_request_log( transaction_response, "Host", "Mpesa Express", integration_request, None, status ) jingrow.log_error(f"Mpesa: Transaction failed with ResultCode {result_code}") return status @jingrow.whitelist() @role_guard.api("billing") def request_for_payment(**kwargs): """request for payments""" team = get_current_team() kwargs.setdefault("team", team) args = jingrow._dict(kwargs) update_tax_id_or_phone_no(team, args.tax_id, args.phone_number) amount = args.request_amount args.request_amount = jingrow.utils.rounded(amount, 2) response = jingrow._dict(generate_stk_push(**args)) handle_api_mpesa_response("CheckoutRequestID", args, response) return response def handle_api_mpesa_response(global_id, request_dict, response): """Response received from API calls returns a global identifier for each transaction, this code is returned during the callback.""" # check error response if response.requestId: req_name = response.requestId error = response else: # global checkout id used as request name req_name = getattr(response, global_id) error = None create_mpesa_request_log(request_dict, "Host", "Mpesa Express", req_name, error, output=response) if error: jingrow.throw(_(response.errorMessage), title=_("Transaction Error")) def create_mpesa_payment_record(transaction_response): """Create a new entry in the Mpesa Payment Record for a successful transaction.""" item_response = transaction_response.get("CallbackMetadata", {}).get("Item", []) mpesa_receipt_number = fetch_param_value(item_response, "MpesaReceiptNumber", "Name") transaction_time = fetch_param_value(item_response, "TransactionDate", "Name") phone_number = fetch_param_value(item_response, "PhoneNumber", "Name") transaction_id = transaction_response.get("CheckoutRequestID") amount = fetch_param_value(item_response, "Amount", "Name") merchant_request_id = transaction_response.get("MerchantRequestID") info = get_details_from_request_log(transaction_id) gateway_name = get_payment_gateway(info.partner) # Create a new entry in M-Pesa Payment Record data = { "transaction_id": transaction_id, "amount": amount, "team": jingrow.get_value("Team", info.team, "user"), "tax_id": jingrow.get_value("Team", info.team, "mpesa_tax_id"), "default_currency": "KES", "rate": info.requested_amount, } if jingrow.db.exists("Mpesa Payment Record", {"transaction_id": transaction_id}): return try: mpesa_invoice, invoice_name = create_invoice_partner_site(data, gateway_name) except Exception as e: jingrow.log_error(f"Failed to create mpesa invoice on partner site: {e}") mpesa_invoice = invoice_name = None try: payment_record = jingrow.get_pg( { "pagetype": "Mpesa Payment Record", "transaction_id": transaction_id, "transaction_time": parse_datetime(transaction_time), "transaction_type": "Mpesa Express", "team": info.team, "phone_number": str(phone_number), "amount": info.requested_amount, "grand_total": amount, "merchant_request_id": merchant_request_id, "payment_partner": info.partner, "amount_usd": info.amount_usd, "exchange_rate": info.exchange_rate, "local_invoice": mpesa_invoice, "mpesa_receipt_number": mpesa_receipt_number, } ) payment_record.insert(ignore_permissions=True) payment_record.submit() except Exception: jingrow.log_error("Failed to create Mpesa Payment Record") raise """create payment partner transaction which will then create balance transaction""" create_payment_partner_transaction( info.team, info.partner, info.exchange_rate, info.amount_usd, info.requested_amount, gateway_name ) mpesa_details = { "mpesa_receipt_number": mpesa_receipt_number, "mpesa_merchant_id": merchant_request_id, "mpesa_payment_record": payment_record.name, "mpesa_request_id": transaction_id, "mpesa_invoice": invoice_name, } create_balance_transaction_and_invoice(info.team, info.amount_usd, mpesa_details) jingrow.msgprint(_("Mpesa Payment Record entry created successfully")) def create_balance_transaction_and_invoice(team, amount, mpesa_details): try: balance_transaction = jingrow.get_pg( pagetype="Balance Transaction", team=team, source="Prepaid Credits", type="Adjustment", amount=amount, description=mpesa_details.get("mpesa_payment_record"), paid_via_local_pg=1, ) balance_transaction.insert(ignore_permissions=True) balance_transaction.submit() invoice = jingrow.get_pg( pagetype="Invoice", team=team, type="Prepaid Credits", status="Paid", total=amount, amount_due=amount, amount_paid=amount, amount_due_with_tax=amount, due_date=jingrow.utils.nowdate(), mpesa_merchant_id=mpesa_details.get("mpesa_merchant_id", ""), mpesa_receipt_number=mpesa_details.get("mpesa_receipt_number", ""), mpesa_request_id=mpesa_details.get("mpesa_request_id", ""), mpesa_payment_record=mpesa_details.get("mpesa_payment_record", ""), mpesa_invoice=mpesa_details.get("mpesa_invoice", ""), ) invoice.append( "items", { "description": "Prepaid Credits", "document_type": "Balance Transaction", "document_name": balance_transaction.name, "quantity": 1, "rate": amount, }, ) invoice.insert(ignore_permissions=True) invoice.submit() _enqueue_finalize_unpaid_invoices_for_team(team) except Exception: jingrow.log_error("Mpesa: Failed to create balance transaction and invoice") def parse_datetime(date): from datetime import datetime return datetime.strptime(str(date), "%Y%m%d%H%M%S") @jingrow.whitelist() @role_guard.api("billing") def billing_forecast(): """ Get billing forecast and breakdown data for the current month. """ team = get_current_team(True) # Get dates and related info date_info = _get_date_context() # Get last and current month invoice currency and totals invoice_data = _get_invoice_data(team.name, date_info) # Calculate month-end forecast amount and per-service breakdown of forecasts forecast_data = _calculate_forecast_data(team.name, team.currency, date_info) # Get usage breakdowns for last month, current month-to-date and forecasted month-end usage_breakdown = _get_usage_data_breakdown(invoice_data, forecast_data, date_info["days_remaining"]) # Calculate month-over-month and month-to-date % changes changes = _calculate_percentage_changes( team.name, invoice_data, forecast_data["forecasted_total"], date_info ) return { "current_month_to_date_cost": invoice_data["current_month_total"], "forecasted_month_end": forecast_data["forecasted_total"], "last_month_cost": invoice_data["last_month_total"], "usage_breakdown": usage_breakdown, "month_over_month_change": changes["month_over_month"], "mtd_change": changes["mtd_change"], "currency": team.currency, } def _get_date_context(): """Get all date-related data in one place for billing forecast.""" from jingrow.utils import add_days, add_months, get_last_day, getdate current_date = getdate() current_month_start = current_date.replace(day=1) current_month_end = get_last_day(current_date) last_month_end = add_days(current_month_start, -1) last_month_start = last_month_end.replace(day=1) last_month_same_date = add_months(current_date, -1) days_in_month = (current_month_end - current_month_start).days + 1 days_passed = (current_date - current_month_start).days + 1 days_remaining = max(days_in_month - days_passed, 0) return { "current_date": current_date, "current_month_start": current_month_start, "current_month_end": current_month_end, "last_month_start": last_month_start, "last_month_end": last_month_end, "last_month_same_date": last_month_same_date, "days_in_month": days_in_month, "days_passed": days_passed, "days_remaining": days_remaining, } def _get_invoice_data(team_name: str, date_info: dict): """Get current and last month invoice data.""" current_invoice = _get_invoice_based_on_due_date(team_name, date_info["current_month_end"]) last_month_invoice = _get_invoice_based_on_due_date(team_name, date_info["last_month_end"]) return { "current_invoice": current_invoice, "last_month_invoice": last_month_invoice, "current_month_total": current_invoice.total if current_invoice else 0, "last_month_total": last_month_invoice.total if last_month_invoice else 0, } def _get_invoice_based_on_due_date(team_name, due_date): return jingrow.db.get_value( "Invoice", {"team": team_name, "due_date": due_date, "docstatus": ("!=", 2)}, ["name", "total", "currency"], as_dict=True, ) def _calculate_forecast_data( team: str, currency: str, date_info: dict ) -> dict[str, float | dict[str, float]]: """Calculate monthly total cost of all active subscriptions and forecasted cost for remaining days in the month""" from jingrow.utils import flt subscriptions = _get_active_subscriptions(team) days_remaining = date_info["days_remaining"] days_in_month = date_info["days_in_month"] forecasted_month_end = 0 per_service_forecast: dict[str, float] = {} # Forecasted remaining cost per service price_field = "price_usd" if currency == "USD" else "price_inr" for sub in subscriptions: plan = jingrow.db.get_value(sub.plan_type, sub.plan, [price_field], as_dict=True) if not plan: continue price = plan.get(price_field, 0) if price > 0: forecasted_month_end += price # Forecasted remaining cost for this service if days_remaining > 0: remaining_cost = (price / days_in_month) * days_remaining per_service_forecast[sub.document_type] = flt( per_service_forecast.get(sub.document_type, 0) + remaining_cost ) return { "forecasted_total": forecasted_month_end, "subscription_forecast": per_service_forecast, } def _get_active_subscriptions(team: str): """Get all active subscriptions for a team.""" Subscription = jingrow.qb.PageType("Subscription") return ( jingrow.qb.from_(Subscription) .select(Subscription.document_type, Subscription.plan_type, Subscription.plan) .where((Subscription.team == team) & (Subscription.enabled == 1)) .run(as_dict=True) ) def _get_usage_data_breakdown(invoice_data: dict, forecast_data: dict, days_remaining: int = 0): """Get usage breakdown grouped by document_type.""" current_breakdown = ( _get_usage_breakdown(invoice_data["current_invoice"].name) if invoice_data["current_invoice"] else {} ) last_month_breakdown = ( _get_usage_breakdown(invoice_data["last_month_invoice"].name) if invoice_data["last_month_invoice"] else {} ) forecasted_breakdown = _get_forecasted_usage_breakdown( current_breakdown, forecast_data["subscription_forecast"], days_remaining ) return { "month_to_date_usage_breakdown": current_breakdown, "last_month_usage_breakdown": last_month_breakdown, "forecasted_usage_breakdown": forecasted_breakdown, } def _get_usage_breakdown(invoice: str) -> dict[str, float]: if not invoice: return {} invoice_pg = jingrow.get_pg("Invoice", invoice) service_costs: dict[str, float] = {} for item in invoice_pg.items: service = item.document_type service_costs[service] = service_costs.get(service, 0.0) + float(item.amount) return service_costs def _get_forecasted_usage_breakdown( current_usage: dict, subscription_forecast: dict, days_remaining: int ) -> dict: # Consider usage so far as well as active subscriptions to forecast month-end usage breakdown if not subscription_forecast and not current_usage: return {} if days_remaining == 0: # if end of month, use actual usage return current_usage forecasted_usage_breakdown = {} for service in set(list(current_usage.keys()) + list(subscription_forecast.keys())): forecasted_usage_breakdown[service] = current_usage.get(service, 0) + subscription_forecast.get( service, 0 ) return forecasted_usage_breakdown def _calculate_percentage_changes(team_name: str, invoice_data: dict, forecasted_total, date_info: dict): """Calculate month-over-month and MTD changes.""" from jingrow.utils import flt # Month-over-month change month_over_month_change = 0 last_month_total = invoice_data["last_month_total"] if last_month_total > 0: month_over_month_change = (forecasted_total - last_month_total) / last_month_total * 100 # Month-to-date change mtd_change = _calculate_mtd_change( team_name, invoice_data["current_month_total"], date_info["last_month_start"], date_info["last_month_same_date"], ) return { "month_over_month": flt(month_over_month_change, 2), "mtd_change": flt(mtd_change, 2), } def _calculate_mtd_change(team: str, current_mtd_cost: float, last_month_start, last_month_same_date): from jingrow.utils import flt last_mtd_total = _get_usage_records_total_for_date_range(team, last_month_start, last_month_same_date) mtd_change = 0 if last_mtd_total > 0: mtd_change = ((current_mtd_cost - last_mtd_total) / last_mtd_total) * 100 return flt(mtd_change, 2) def _get_usage_records_total_for_date_range(team: str, start_date, end_date): """Get total amount from Usage Records for a team in a date range.""" from jingrow.query_builder.functions import Sum UsageRecord = jingrow.qb.PageType("Usage Record") total_amount = ( jingrow.qb.from_(UsageRecord) .select(Sum(UsageRecord.amount)) .where( (UsageRecord.team == team) & (UsageRecord.date >= start_date) & (UsageRecord.date <= end_date) & (UsageRecord.docstatus == 1) ) .run(pluck=True) ) return total_amount[0] or 0