Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,22 @@ Providers are configured from the dashboard rather than the desk. Each keeps its
- **Stripe** — Cards and wallets, worldwide.
- **Telr** — Cards and local methods across the GCC.
- **Tabby** — Buy now, pay later in four instalments. MENA.
- **PayPal** — PayPal wallet payments. Not for INR or Gulf currencies.
- **Braintree** — Cards with 3-D Secure, worldwide. Needs a merchant account per currency.
- **Cash on delivery** — With its own fee, a threshold above which the fee is waived, and the account it books to.

Gateway callbacks are idempotent, so a replayed webhook racing a shopper's return cannot bill twice.

#### Gateways from other apps

Every gateway bwh_payments ships gets a card on its own, from `bwh_payments.api.get_gateways()`. A payment
provider app outside bwh_payments adds its own card with `commera_payment_gateways = [...]` in its `hooks.py`,
next to `commera_api_version`: dotted paths, each returning `{slug, label, blurb, settings_doctype, docs_url,
capabilities}`, where `label` is the Payment Gateway Profile's name. Commera never names a provider in code; it acts on
`capabilities` instead. With `reusable_checkout`, a shopper who returns to checkout is sent back to the
still-open checkout link, and the cart is kept while that payment is pending. Checkout only offers
gateways that can take the cart's currency.

<div>
<img width="1402" alt="Payment providers and cash on delivery settings" src=".github/screenshots/payments.png">
</div>
Expand Down
90 changes: 57 additions & 33 deletions commera/api/admin/payments.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,63 +2,56 @@
# For license information, please see license.txt

import frappe
from bwh_payments.api import GatewayInfo, get_gateways

from commera.api.admin.integrations import describe_integration, save_integration
from commera.migrate import add_payment_mode
from commera.plugin_events import get_handler_app, is_supported_app

PROFILE_DOCTYPE = "Payment Gateway Profile"
REQUIRED_GATEWAY_FIELDS = {"slug", "label", "settings_doctype"}
PROFILE_SETTINGS_FIELDNAME = "gateway_settings"
WEBHOOK_PATH = "/api/method/bwh_payments.bwh_payments.webhook.handle?gateway={profile}"

# Order here is the order the cards render in.
PAYMENT_GATEWAYS = (
{
"slug": "razorpay",
"label": "Razorpay",
"blurb": "Cards, UPI, netbanking and wallets. India.",
"settings_doctype": "Razorpay Gateway Settings",
"docs_url": "https://dashboard.razorpay.com/app/website-app-settings/webhooks",
},
{
"slug": "stripe",
"label": "Stripe",
"blurb": "Cards and wallets, worldwide.",
"settings_doctype": "Stripe Gateway Settings",
"docs_url": "https://dashboard.stripe.com/apikeys",
},
{
"slug": "telr",
"label": "Telr",
"blurb": "Cards and local methods across the GCC.",
"settings_doctype": "Telr Gateway Settings",
"docs_url": "https://telr.com/support/knowledge-base/hosted-payment-page-integration-guide/",
},
{
"slug": "tabby",
"label": "Tabby",
"blurb": "Buy now, pay later in four instalments. MENA.",
"settings_doctype": "Tabby Gateway Settings",
"docs_url": "https://docs.tabby.ai/",
},
)


def add_gateway_payment_mode(integration):
"""ERPNext refuses to post a Payment Entry for a gateway with no matching Mode of Payment."""
add_payment_mode(integration["label"], "Bank")


def get_payment_gateways():
"""bwh_payments' gateways, then each provider app's `commera_payment_gateways` card; a card cannot replace one."""
gateways = [get_bwh_payments_card(gateway) for gateway in get_gateways()]
slugs = {gateway["slug"] for gateway in gateways}
for method in get_gateway_hooks():
gateway = get_hook_gateway(method)
if gateway and gateway["slug"] not in slugs:
gateways.append(gateway)
slugs.add(gateway["slug"])
return gateways


def get_payment_registry():
"""The gateway registry as integration entries, with what every payment gateway shares filled in."""
return [
{
"capabilities": [],
**gateway,
"profile_doctype": PROFILE_DOCTYPE,
"profile_settings_fieldname": PROFILE_SETTINGS_FIELDNAME,
"webhook_path": WEBHOOK_PATH,
"on_enable": add_gateway_payment_mode,
}
for gateway in PAYMENT_GATEWAYS
for gateway in get_payment_gateways()
]


def get_gateways_with_capability(capability: str) -> list[str]:
"""Payment Gateway Profile names whose provider declares `capability`."""
return [
gateway["label"]
for gateway in get_payment_gateways()
if capability in gateway.get("capabilities", ())
]


Expand Down Expand Up @@ -86,3 +79,34 @@ def save_payment_integration(slug: str, enabled: int | str, values: dict | str |
return save_integration(
get_payment_integration(slug), enabled, frappe.parse_json(values) if values else {}
)


def get_bwh_payments_card(gateway: GatewayInfo) -> dict:
return {
"slug": frappe.scrub(gateway.name),
"label": gateway.name,
"blurb": gateway.description,
"settings_doctype": gateway.settings_doctype,
"docs_url": gateway.docs_url,
"capabilities": gateway.capabilities,
}


def get_gateway_hooks() -> list[str]:
return [
method
for method in frappe.get_hooks("commera_payment_gateways")
if is_supported_app(get_handler_app(method))
]


def get_hook_gateway(method: str) -> dict | None:
# Read on every checkout, so a broken provider app is logged and skipped, never raised.
try:
gateway = frappe.get_attr(method)()
if missing := REQUIRED_GATEWAY_FIELDS - set(gateway):
raise ValueError(f"Gateway card is missing {', '.join(sorted(missing))}")
return gateway
except Exception:
frappe.log_error(title=f"commera_payment_gateways hook failed: {method}"[:140], defer_insert=True)
return None
70 changes: 61 additions & 9 deletions commera/api/payments.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
from contextlib import contextmanager

import frappe
from bwh_payments.api import get_gateway_options
from bwh_payments.bwh_payments.utils import get_available_payment_modes, resolve_payment_mode
from bwh_payments.currency import to_minor_units
from erpnext.accounts.doctype.journal_entry.journal_entry import get_default_bank_cash_account
from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry
from erpnext.accounts.doctype.pricing_rule.utils import validate_coupon_code
Expand Down Expand Up @@ -53,6 +55,12 @@ class CheckoutPriceChangedError(frappe.ValidationError):
from erpnext.selling.doctype.sales_order.sales_order import make_sales_invoice

COD_PAYMENT_MODE = "COD"
# A gateway capability: its checkout link stays payable after the shopper closes the tab.
REUSABLE_CHECKOUT = "reusable_checkout"


def get_payment_modes_for_currency(currency: str) -> list[str]:
return [option.name for option in get_gateway_options(currency)]


def is_cod(payment_mode: str | None) -> bool:
Expand All @@ -72,6 +80,43 @@ def get_open_gateway_payment_request(quotation_name: str) -> str | None:
return None


def get_reusable_checkout_gateways() -> list[str]:
# Imported here: commera.api.admin.payments imports commera.migrate, which imports this module.
from commera.api.admin.payments import get_gateways_with_capability

return get_gateways_with_capability(REUSABLE_CHECKOUT)


def get_reusable_checkout_url(
quotation, gateway: str, payment_mode: str, expected_total: float | None
) -> str | None:
request_name = get_open_gateway_payment_request(quotation.name)
if not request_name:
return None

payment_request = frappe.get_doc("Gateway Payment Request", request_name)
if payment_request.status != "Pending" or payment_request.gateway != gateway:
return None

payment_request.sync_status()
if payment_request.status == "Paid":
return get_confirmation_url(payment_request.name)
if payment_request.status != "Pending" or payment_request.currency_code != quotation.currency:
return None

if to_minor_units(payment_request.amount, quotation.currency) != to_minor_units(
get_charge_amount(quotation), quotation.currency
):
return None

# The checks a new checkout runs, so a plugin that has since dropped the gateway or refuses the cart wins.
if gateway not in get_checkout_payment_methods(quotation, strict=True):
return None
validate_cart(quotation)
validate_expected_total(quotation, payment_mode, expected_total)
return payment_request.order_url


def validate_cart_is_not_in_checkout(quotation_name: str):
if not quotation_name:
return
Expand Down Expand Up @@ -143,6 +188,11 @@ def open_checkout(
quotation, payment_mode: str, delivery_option: str | None = None, expected_total: float | None = None
):
validate_delivery_option(quotation, delivery_option)
gateway = resolve_payment_mode(payment_mode)
if gateway in get_reusable_checkout_gateways():
if order_url := get_reusable_checkout_url(quotation, gateway, payment_mode, expected_total):
return {"order_url": order_url}

validate_cart_is_not_in_checkout(quotation.name)
quotation.flags.strict_plugin_fees = True
update_delivery_charges(quotation)
Expand All @@ -155,13 +205,13 @@ def open_checkout(
refuse_payment(_("Cash on delivery is not available."), quotation.name)
return {"order_url": get_confirmation_url(quotation.name, payment_mode=COD_PAYMENT_MODE)}

gateway = resolve_payment_mode(payment_mode)
if not gateway:
available_gateways = get_payment_modes_for_currency(quotation.currency)
if not gateway or gateway not in available_gateways:
refuse_payment(
_("Please select a valid payment mode."),
_("Please select a payment mode available for this currency."),
quotation.name,
requested=payment_mode,
available=get_available_payment_modes(),
available=available_gateways,
)
if gateway not in payment_methods:
refuse_payment(
Expand All @@ -187,7 +237,9 @@ def open_checkout(


def get_checkout_payment_methods(quotation, strict: bool = False) -> list[str]:
payment_methods = list(get_available_payment_modes())
payment_methods = list(
get_payment_modes_for_currency(quotation.currency) if quotation else get_available_payment_modes()
)
if frappe.get_cached_value("Commera Settings", "Commera Settings", "cod_enabled"):
payment_methods.append(COD_PAYMENT_MODE)
if not quotation:
Expand Down Expand Up @@ -332,18 +384,18 @@ def stamp_order_owner(sales_order, shopper: str) -> None:
sales_order.db_set("owner", shopper, update_modified=False)


def get_order_shopper(quotation) -> str | None:
# A guest cart has no account to hand the order to, and the payment webhook runs as Guest itself.
def get_order_shopper(quotation, by_gateway: bool = False) -> str | None:
# A guest cart has no account to hand the order to. bwh_payments applies a webhook's payment as Administrator.
if quotation.custom_guest_cart_key:
return None
if not is_guest():
if not is_guest() and not (by_gateway and frappe.session.user == "Administrator"):
return frappe.session.user
return frappe.db.exists("User", quotation.contact_email) if quotation.contact_email else None


def place_order(quotation, payment_mode: str, gateway_amount=None, gateway_reference=None):
"""Submit the cart and bill it. Called once per payment; the Quotation docstatus enforces that."""
shopper = get_order_shopper(quotation)
shopper = get_order_shopper(quotation, by_gateway=True)
with system_user_session():
fix_payment_schedule_dates(quotation)
quotation.flags.ignore_permissions = True
Expand Down
11 changes: 6 additions & 5 deletions commera/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,15 @@
{"source": "/products", "target": "/en/products"},
{"source": "/products/(.*)", "target": r"/en/products/\1"},
{"source": "/", "target": "/en"},
{"source": "/cart", "target": "/en/cart"},
{"source": "/cart/checkout", "target": "/en/cart/checkout"},
# Gateway return URLs carry `?reference_id=`, so these redirects must keep the query string.
{"source": "/cart", "target": "/en/cart", "forward_query_parameters": True},
{"source": "/cart/checkout", "target": "/en/cart/checkout", "forward_query_parameters": True},
{"source": "/account", "target": "en/account/dashboard"},
{"source": "/account/(.*)", "target": r"/en/account/\1"},
{"source": "/account/(.*)", "target": r"/en/account/\1", "forward_query_parameters": True},
{"source": "/en/account", "target": "/account/dashboard"},
{"source": "/ar/account", "target": "/account/dashboard"},
{"source": "/en-US/(.*)", "target": r"/en/\1"},
{"source": "/en-GB/(.*)", "target": r"/en/\1"},
{"source": "/en-US/(.*)", "target": r"/en/\1", "forward_query_parameters": True},
{"source": "/en-GB/(.*)", "target": r"/en/\1", "forward_query_parameters": True},
]

# website_path_resolver = "commera.utils.resolve_bilingual_path"
Expand Down
19 changes: 15 additions & 4 deletions commera/jobs.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
)
from frappe.utils import add_to_date, get_url, now_datetime

from commera.api.payments import COD_PAYMENT_MODE
from commera.api.payments import COD_PAYMENT_MODE, get_reusable_checkout_gateways
from commera.order_access import get_order_link, set_order_access_key


Expand Down Expand Up @@ -210,7 +210,19 @@ def delete_old_draft_quotations():
},
pluck="name",
)
reusable_gateways = get_reusable_checkout_gateways()
for quotation in old_drafts:
# The gateway's checkout link is still payable, so the cart must outlive the shopper's tab.
if reusable_gateways and frappe.db.exists(
"Gateway Payment Request",
{
"ref_doctype": "Quotation",
"ref_docname": quotation,
"gateway": ["in", reusable_gateways],
"status": "Pending",
},
):
continue
try:
frappe.delete_doc("Quotation", quotation)
except Exception as e:
Expand All @@ -219,9 +231,8 @@ def delete_old_draft_quotations():

def sync_pending_gateway_payments():
"""Settle checkouts the shopper's browser never confirmed, so captured money always reaches an order."""
# ponytail: a request older than the lookback is left to manual reconciliation - delete_old_draft_
# quotations already removes the draft cart it points at after 6 hours, so the order can no longer
# be placed from it. Widen both windows together, never this one alone.
# Requests older than the lookback need a webhook or manual reconciliation. Carts of reusable-checkout
# gateways are kept while their request is pending, so a late webhook can still place the order.
now = now_datetime()
pending_requests = frappe.get_all(
"Gateway Payment Request",
Expand Down
Loading
Loading