diff --git a/README.md b/README.md
index a38c147c..128ffbeb 100644
--- a/README.md
+++ b/README.md
@@ -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.
+
diff --git a/commera/api/admin/payments.py b/commera/api/admin/payments.py
index 29ae0b60..0c317345 100644
--- a/commera/api/admin/payments.py
+++ b/commera/api/admin/payments.py
@@ -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", ())
]
@@ -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
diff --git a/commera/api/payments.py b/commera/api/payments.py
index 8377424d..89e63e7a 100644
--- a/commera/api/payments.py
+++ b/commera/api/payments.py
@@ -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
@@ -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:
@@ -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
@@ -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)
@@ -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(
@@ -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:
@@ -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
diff --git a/commera/hooks.py b/commera/hooks.py
index e1ed6b39..0d50df2f 100644
--- a/commera/hooks.py
+++ b/commera/hooks.py
@@ -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"
diff --git a/commera/jobs.py b/commera/jobs.py
index 228f875c..32534933 100644
--- a/commera/jobs.py
+++ b/commera/jobs.py
@@ -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
@@ -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:
@@ -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",
diff --git a/commera/tests/test_admin_integrations.py b/commera/tests/test_admin_integrations.py
index 5ecc1bff..e33b5d93 100644
--- a/commera/tests/test_admin_integrations.py
+++ b/commera/tests/test_admin_integrations.py
@@ -1,6 +1,9 @@
# Copyright (c) 2026, company@bwhstudios.com and Contributors
+from unittest.mock import patch
+
import frappe
+from bwh_payments.api import GatewayInfo, get_gateways
from bwh_payments.bwh_payments.utils import get_available_payment_modes
from frappe.tests import IntegrationTestCase
from frappe.utils.password import get_decrypted_password
@@ -10,7 +13,12 @@
get_integration_linked_doctypes,
get_link_options,
)
-from commera.api.admin.payments import get_payment_integrations, save_payment_integration
+from commera.api.admin.payments import (
+ get_gateways_with_capability,
+ get_payment_gateways,
+ get_payment_integrations,
+ save_payment_integration,
+)
SLUG = "razorpay"
GATEWAY = "Razorpay"
@@ -127,7 +135,10 @@ def test_disabling_switches_both_off_without_deleting(self):
def test_every_registered_gateway_is_described(self):
cards = get_payment_integrations()
- self.assertEqual([card["slug"] for card in cards], ["razorpay", "stripe", "telr", "tabby"])
+ self.assertEqual(
+ [card["slug"] for card in cards], [gateway["slug"] for gateway in get_payment_gateways()]
+ )
+ self.assertTrue({"razorpay", "stripe", "telr", "tabby"} <= {card["slug"] for card in cards})
for card in cards:
self.assertTrue(card["available"], f"{card['slug']} settings doctype is missing")
self.assertTrue(card["webhook_url"].endswith(f"gateway={card['label']}"))
@@ -137,6 +148,74 @@ def test_every_registered_gateway_is_described(self):
[field["fieldname"] for group in card["groups"] for field in group["fields"]],
)
+ def test_bwh_payments_gateways_need_no_registration_in_commera(self):
+ with (
+ patch("commera.api.admin.payments.get_gateways", return_value=[wallet_gateway_info()]),
+ patch("commera.api.admin.payments.get_gateway_hooks", return_value=[]),
+ ):
+ gateways = get_payment_gateways()
+ reusable = get_gateways_with_capability("reusable_checkout")
+
+ self.assertEqual(
+ gateways,
+ [
+ {
+ "slug": "test_wallet",
+ "label": "Test Wallet",
+ "blurb": "A wallet bwh_payments describes.",
+ "settings_doctype": "Test Wallet Settings",
+ "docs_url": "https://wallet.test/docs",
+ "capabilities": ["reusable_checkout"],
+ }
+ ],
+ )
+ self.assertEqual(reusable, ["Test Wallet"])
+
+ def test_a_provider_app_registers_its_gateway_through_the_hook(self):
+ with (
+ patch("commera.api.admin.payments.get_gateways", return_value=[]),
+ patch(
+ "commera.api.admin.payments.get_gateway_hooks", return_value=[f"{__name__}.wallet_gateway"]
+ ),
+ ):
+ gateways = get_payment_gateways()
+ reusable = get_gateways_with_capability("reusable_checkout")
+
+ self.assertEqual(gateways[-1]["slug"], "test-wallet")
+ self.assertEqual(reusable, ["Test Wallet"])
+
+ def test_a_card_from_an_app_built_for_another_commera_api_is_skipped(self):
+ with (
+ patch("commera.api.admin.payments.get_gateways", return_value=[]),
+ patch("commera.api.admin.payments.frappe.get_hooks", return_value=[f"{__name__}.wallet_gateway"]),
+ patch("commera.api.admin.payments.is_supported_app", return_value=False),
+ ):
+ gateways = get_payment_gateways()
+
+ self.assertNotIn("test-wallet", [gateway["slug"] for gateway in gateways])
+
+ def test_a_failing_card_is_logged_and_skipped_instead_of_breaking_the_registry(self):
+ hooks = [f"{__name__}.broken_gateway", f"{__name__}.card_without_slug", f"{__name__}.wallet_gateway"]
+ with (
+ patch("commera.api.admin.payments.get_gateway_hooks", return_value=hooks),
+ patch("commera.api.admin.payments.frappe.log_error") as log_error,
+ ):
+ gateways = get_payment_gateways()
+
+ self.assertEqual(gateways[-1]["slug"], "test-wallet")
+ self.assertEqual(len(gateways), len(get_gateways()) + 1)
+ self.assertEqual(log_error.call_count, 2)
+
+ def test_a_hook_cannot_replace_a_built_in_gateway(self):
+ with patch(
+ "commera.api.admin.payments.get_gateway_hooks", return_value=[f"{__name__}.stripe_impostor"]
+ ):
+ gateways = get_payment_gateways()
+
+ stripe = [gateway for gateway in gateways if gateway["slug"] == "stripe"]
+ self.assertEqual(len(stripe), 1)
+ self.assertEqual(stripe[0]["settings_doctype"], "Stripe Gateway Settings")
+
def test_a_gateway_whose_app_is_gone_is_reported_unavailable(self):
card = describe_integration(
{
@@ -173,3 +252,36 @@ def test_the_picker_searches_a_doctype_a_provider_links_to(self):
def test_the_picker_refuses_a_doctype_no_provider_links_to(self):
# Otherwise the whitelisted picker reads any doctype the session can see.
self.assertRaises(frappe.ValidationError, get_link_options, "User")
+
+
+def wallet_gateway():
+ return {
+ "slug": "test-wallet",
+ "label": "Test Wallet",
+ "blurb": "A wallet registered by another app.",
+ "settings_doctype": "Test Wallet Settings",
+ "docs_url": "https://wallet.test/docs",
+ "capabilities": ["reusable_checkout"],
+ }
+
+
+def wallet_gateway_info():
+ return GatewayInfo(
+ name="Test Wallet",
+ settings_doctype="Test Wallet Settings",
+ description="A wallet bwh_payments describes.",
+ docs_url="https://wallet.test/docs",
+ capabilities=["reusable_checkout"],
+ )
+
+
+def stripe_impostor():
+ return {**wallet_gateway(), "slug": "stripe", "settings_doctype": "Impostor Settings"}
+
+
+def broken_gateway():
+ raise RuntimeError("provider app bug")
+
+
+def card_without_slug():
+ return {"label": "No Slug", "settings_doctype": "No Slug Settings"}
diff --git a/commera/tests/test_payment_hooks.py b/commera/tests/test_payment_hooks.py
index 7cd0c72a..b30a9503 100644
--- a/commera/tests/test_payment_hooks.py
+++ b/commera/tests/test_payment_hooks.py
@@ -339,6 +339,15 @@ def test_a_paid_payment_creates_one_sales_order_and_one_sales_invoice(self):
)
self.assertEqual(frappe.db.get_value("Payment Entry", payment_entries[0], "mode_of_payment"), GATEWAY)
+ def test_an_order_placed_by_a_webhook_belongs_to_the_shopper(self):
+ """A webhook runs as Administrator, and the account pages list only orders the shopper owns."""
+ frappe.set_user("Administrator")
+
+ on_payment_request_update(self.payment_request)
+
+ sales_order = self.submitted_sales_orders()[0]
+ self.assertEqual(frappe.db.get_value("Sales Order", sales_order, "owner"), self.shopper)
+
def test_a_paid_order_is_due_on_its_order_date_on_the_header_and_every_line(self):
on_payment_request_update(self.payment_request)
diff --git a/commera/tests/test_reusable_checkout.py b/commera/tests/test_reusable_checkout.py
new file mode 100644
index 00000000..a9a52166
--- /dev/null
+++ b/commera/tests/test_reusable_checkout.py
@@ -0,0 +1,230 @@
+from datetime import datetime
+from types import SimpleNamespace
+from unittest.mock import Mock, patch
+
+import frappe
+from frappe.tests import UnitTestCase
+
+from commera.api.payments import get_checkout_payment_methods, get_reusable_checkout_url, open_checkout
+from commera.jobs import delete_old_draft_quotations
+
+GATEWAY = "Test Wallet"
+
+
+def make_quotation(amount=20):
+ return SimpleNamespace(
+ name="WALLET-CART-1",
+ currency="USD",
+ rounded_total=amount,
+ grand_total=amount,
+ custom_delivery_option=None,
+ flags=frappe._dict(),
+ )
+
+
+class TestReusableCheckout(UnitTestCase):
+ def test_checkout_offers_gateways_for_the_cart_currency(self):
+ with (
+ patch("commera.api.payments.get_payment_modes_for_currency", return_value=[GATEWAY]) as available,
+ patch("commera.api.payments.frappe.get_cached_value", return_value=0),
+ patch(
+ "commera.api.payments.filter_payment_methods",
+ side_effect=lambda quotation, methods, strict: methods,
+ ),
+ ):
+ payment_methods = get_checkout_payment_methods(make_quotation())
+
+ available.assert_called_once_with("USD")
+ self.assertEqual(payment_methods, [GATEWAY])
+
+ def test_an_unsupported_currency_is_rejected_before_a_payment_request_is_created(self):
+ quotation = make_quotation()
+ with (
+ patch("commera.api.payments.get_payment_modes_for_currency", return_value=[]) as available,
+ patch("commera.api.payments.resolve_payment_mode", return_value=GATEWAY),
+ patch("commera.api.payments.get_reusable_checkout_gateways", return_value=[GATEWAY]),
+ patch("commera.api.payments.validate_cart_is_not_in_checkout"),
+ patch("commera.api.payments.update_delivery_charges"),
+ patch("commera.api.payments.validate_cart"),
+ patch("commera.api.payments.validate_expected_total"),
+ patch("commera.api.payments.get_checkout_payment_methods", return_value=[]),
+ patch("commera.api.payments._", side_effect=lambda message: message),
+ patch("commera.api.payments.refuse_payment", side_effect=frappe.ValidationError),
+ patch("commera.api.payments.frappe.get_doc") as get_doc,
+ ):
+ with self.assertRaises(frappe.ValidationError):
+ open_checkout(quotation, GATEWAY)
+
+ available.assert_called_with("USD")
+ get_doc.assert_not_called()
+
+ def test_a_repeat_checkout_reuses_the_open_link_of_a_reusable_gateway(self):
+ quotation = make_quotation()
+ with (
+ patch("commera.api.payments.resolve_payment_mode", return_value=GATEWAY),
+ patch("commera.api.payments.get_reusable_checkout_gateways", return_value=[GATEWAY]),
+ patch("commera.api.payments.get_reusable_checkout_url", return_value="https://wallet.test/old"),
+ patch("commera.api.payments.validate_cart_is_not_in_checkout") as validate_cart,
+ patch("commera.api.payments.frappe.get_doc") as get_doc,
+ ):
+ result = open_checkout(quotation, GATEWAY)
+
+ self.assertEqual(result, {"order_url": "https://wallet.test/old"})
+ validate_cart.assert_not_called()
+ get_doc.assert_not_called()
+
+ def test_a_changed_cart_cannot_reuse_its_old_link(self):
+ quotation = make_quotation(amount=30)
+ payment_request = SimpleNamespace(
+ status="Pending",
+ gateway=GATEWAY,
+ currency_code="USD",
+ amount=20,
+ order_url="https://wallet.test/old",
+ sync_status=Mock(),
+ )
+ with (
+ patch("commera.api.payments.get_open_gateway_payment_request", return_value="GPR-1"),
+ patch("commera.api.payments.frappe.get_doc", return_value=payment_request),
+ patch(
+ "commera.api.payments.to_minor_units", side_effect=lambda amount, currency: int(amount * 100)
+ ),
+ ):
+ self.assertIsNone(get_reusable_checkout_url(quotation, GATEWAY, GATEWAY, None))
+
+ payment_request.sync_status.assert_called_once_with()
+
+ def test_a_reused_link_must_match_the_total_the_shopper_saw(self):
+ quotation = make_quotation()
+ payment_request = SimpleNamespace(
+ status="Pending",
+ gateway=GATEWAY,
+ currency_code="USD",
+ amount=20,
+ order_url="https://wallet.test/old",
+ sync_status=Mock(),
+ )
+ with (
+ patch("commera.api.payments.get_open_gateway_payment_request", return_value="GPR-1"),
+ patch("commera.api.payments.frappe.get_doc", return_value=payment_request),
+ patch("commera.api.payments.get_checkout_payment_methods", return_value=[GATEWAY]),
+ patch("commera.api.payments.validate_cart"),
+ patch(
+ "commera.api.payments.to_minor_units", side_effect=lambda amount, currency: int(amount * 100)
+ ),
+ patch(
+ "commera.api.payments.validate_expected_total", side_effect=frappe.ValidationError
+ ) as validate,
+ ):
+ with self.assertRaises(frappe.ValidationError):
+ get_reusable_checkout_url(quotation, GATEWAY, GATEWAY, 25)
+
+ validate.assert_called_once_with(quotation, GATEWAY, 25)
+
+ def test_a_paid_request_goes_to_confirmation(self):
+ quotation = make_quotation()
+ payment_request = SimpleNamespace(name="GPR-1", status="Pending", gateway=GATEWAY)
+
+ def mark_paid():
+ payment_request.status = "Paid"
+
+ payment_request.sync_status = mark_paid
+ with (
+ patch("commera.api.payments.get_open_gateway_payment_request", return_value="GPR-1"),
+ patch("commera.api.payments.frappe.get_doc", return_value=payment_request),
+ patch(
+ "commera.api.payments.get_confirmation_url", return_value="/orders/confirmation"
+ ) as confirm,
+ ):
+ self.assertEqual(
+ get_reusable_checkout_url(quotation, GATEWAY, GATEWAY, None), "/orders/confirmation"
+ )
+
+ confirm.assert_called_once_with("GPR-1")
+
+ def test_cleanup_keeps_a_cart_with_a_pending_reusable_checkout(self):
+ exists = Mock(return_value=True)
+ delete_doc = Mock()
+ fake_frappe = SimpleNamespace(
+ get_all=Mock(return_value=["WALLET-CART-1"]),
+ db=SimpleNamespace(exists=exists),
+ delete_doc=delete_doc,
+ )
+ with (
+ patch("commera.jobs.frappe", fake_frappe),
+ patch("commera.jobs.now_datetime", return_value=datetime(2026, 9, 24)),
+ patch("commera.jobs.get_reusable_checkout_gateways", return_value=[GATEWAY]),
+ ):
+ delete_old_draft_quotations()
+
+ exists.assert_called_once_with(
+ "Gateway Payment Request",
+ {
+ "ref_doctype": "Quotation",
+ "ref_docname": "WALLET-CART-1",
+ "gateway": ["in", [GATEWAY]],
+ "status": "Pending",
+ },
+ )
+ delete_doc.assert_not_called()
+
+ def test_cleanup_deletes_a_stale_cart_when_no_gateway_keeps_checkouts_open(self):
+ delete_doc = Mock()
+ fake_frappe = SimpleNamespace(
+ get_all=Mock(return_value=["WALLET-CART-1"]),
+ db=SimpleNamespace(exists=Mock(return_value=True)),
+ delete_doc=delete_doc,
+ )
+ with (
+ patch("commera.jobs.frappe", fake_frappe),
+ patch("commera.jobs.now_datetime", return_value=datetime(2026, 9, 24)),
+ patch("commera.jobs.get_reusable_checkout_gateways", return_value=[]),
+ ):
+ delete_old_draft_quotations()
+
+ delete_doc.assert_called_once_with("Quotation", "WALLET-CART-1")
+
+ def test_a_gateway_without_the_capability_never_reuses_a_link(self):
+ quotation = make_quotation()
+ with (
+ patch("commera.api.payments.resolve_payment_mode", return_value=GATEWAY),
+ patch("commera.api.payments.get_reusable_checkout_gateways", return_value=[]),
+ patch("commera.api.payments.get_reusable_checkout_url") as reuse,
+ patch("commera.api.payments.validate_cart_is_not_in_checkout", side_effect=StopIteration),
+ ):
+ with self.assertRaises(StopIteration):
+ open_checkout(quotation, GATEWAY)
+
+ reuse.assert_not_called()
+
+ def test_a_link_is_not_reused_once_a_plugin_stops_offering_its_gateway(self):
+ with self.patch_open_request():
+ with patch("commera.api.payments.get_checkout_payment_methods", return_value=["Stripe"]):
+ self.assertIsNone(get_reusable_checkout_url(make_quotation(), GATEWAY, GATEWAY, None))
+
+ def test_a_link_is_not_reused_for_a_cart_a_plugin_refuses(self):
+ with self.patch_open_request():
+ with (
+ patch("commera.api.payments.get_checkout_payment_methods", return_value=[GATEWAY]),
+ patch("commera.api.payments.validate_cart", side_effect=frappe.ValidationError) as validate,
+ ):
+ with self.assertRaises(frappe.ValidationError):
+ get_reusable_checkout_url(make_quotation(), GATEWAY, GATEWAY, None)
+
+ validate.assert_called_once()
+
+ def patch_open_request(self):
+ payment_request = SimpleNamespace(
+ status="Pending",
+ gateway=GATEWAY,
+ currency_code="USD",
+ amount=20,
+ order_url="https://wallet.test/old",
+ sync_status=Mock(),
+ )
+ return patch.multiple(
+ "commera.api.payments",
+ get_open_gateway_payment_request=Mock(return_value="GPR-1"),
+ to_minor_units=Mock(side_effect=lambda amount, currency: int(amount * 100)),
+ frappe=SimpleNamespace(get_doc=Mock(return_value=payment_request)),
+ )