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. +
Payment providers and cash on delivery settings
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)), + )