From 5fd843d60c8ec106729cc340c9c57002b55ab9df Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Wed, 7 Oct 2026 11:29:21 +0530 Subject: [PATCH 1/3] test: pin the Razorpay checkout journey before the services refactor Covers checkout link, paid webhook, replay, return-page confirm, abandoned cart release, paid cart lock, pending sweep and refund Payment Entry. Only the gateway driver helpers know the transport fake, so the bwh_payments Razorpay refactor may change their bodies but never the tests. Commits are suppressed for the class: the Razorpay controller logs every call through create_request_log, which commits. --- commera/tests/test_razorpay_checkout.py | 294 ++++++++++++++++++++++++ 1 file changed, 294 insertions(+) create mode 100644 commera/tests/test_razorpay_checkout.py diff --git a/commera/tests/test_razorpay_checkout.py b/commera/tests/test_razorpay_checkout.py new file mode 100644 index 00000000..ed288a25 --- /dev/null +++ b/commera/tests/test_razorpay_checkout.py @@ -0,0 +1,294 @@ +# Copyright (c) 2026, company@bwhstudios.com and Contributors + +from unittest.mock import patch + +import frappe +from bwh_payments.bwh_payments import webhook +from bwh_payments.bwh_payments.doctype.razorpay_gateway_settings import razorpay_gateway_settings +from bwh_payments.bwh_payments.doctype.razorpay_gateway_settings.test_razorpay_gateway_settings import ( + RAZORPAY_GATEWAY, + RAZORPAY_WEBHOOK_SECRET, + configure_razorpay_gateway, +) +from bwh_payments.currency import to_minor_units +from bwh_payments.tests.fake_razorpay import ( + FakeRazorpay, + build_payment_link_paid_event, + sign_razorpay_payload, +) +from frappe.tests import IntegrationTestCase +from frappe.utils import add_to_date, getdate, now_datetime +from frappe.utils.data import flt +from werkzeug.test import EnvironBuilder +from werkzeug.wrappers import Request + +from commera.api.payments import confirm_payment, open_checkout, validate_cart_is_not_in_checkout +from commera.api.shipping import get_charge_amount +from commera.jobs import sync_pending_gateway_payments +from commera.tests import test_payment_hooks +from commera.tests.test_payment_hooks import COMPANY, DEFAULT_CASH_ACCOUNT + + +class TestRazorpayCheckout(IntegrationTestCase): + """The storefront's Razorpay journey, checkout to refund. Only the "gateway driver" helpers know how the + transport is faked: a bwh_payments refactor may change their bodies, never the tests.""" + + # The storefront fixtures the Stripe suite already builds; only the gateway differs here. Reached through + # the module and dropped after: a TestCase at module scope would be collected and run a second time. + StripeSuite = test_payment_hooks.TestPaymentHookIdempotency + ensure_fiscal_year = StripeSuite.ensure_fiscal_year + ensure_price_list = StripeSuite.ensure_price_list + create_item = StripeSuite.create_item + create_customer = StripeSuite.create_customer + create_contact = StripeSuite.create_contact + create_cart_quotation = StripeSuite.create_cart_quotation + create_shopper_user = StripeSuite.create_shopper_user + submitted_sales_orders = StripeSuite.submitted_sales_orders + submitted_sales_invoices = StripeSuite.submitted_sales_invoices + submitted_payment_entries = StripeSuite.submitted_payment_entries + create_refund_payment_entry = StripeSuite.create_refund_payment_entry + del StripeSuite + + @classmethod + def setUpClass(cls): + super().setUpClass() + # Every Razorpay call writes an Integration Request through create_request_log, which commits. The + # test case only rolls back at class end, so without this the whole journey would land on the site. + commit_patch = patch.object(frappe.db, "commit") + commit_patch.start() + cls.addClassCleanup(cls.discard_class_writes, commit_patch) + + configure_razorpay_gateway() + cls.ensure_mode_of_payment() + cls.ensure_price_list() + cls.ensure_fiscal_year() + + @classmethod + def discard_class_writes(cls, commit_patch): + frappe.db.rollback() + commit_patch.stop() + # Also empties the per-worker payment modes cache, or the next class would see Razorpay offered. + frappe.clear_cache() + + @classmethod + def ensure_mode_of_payment(cls): + """place_order posts the Payment Entry under a Mode of Payment named exactly like the gateway.""" + if not frappe.db.exists("Mode of Payment", RAZORPAY_GATEWAY): + frappe.get_doc( + { + "doctype": "Mode of Payment", + "mode_of_payment": RAZORPAY_GATEWAY, + "type": "Bank", + "enabled": 1, + } + ).insert(ignore_permissions=True) + + mode_of_payment = frappe.get_doc("Mode of Payment", RAZORPAY_GATEWAY) + if not any(row.company == COMPANY for row in mode_of_payment.accounts): + mode_of_payment.append("accounts", {"company": COMPANY, "default_account": DEFAULT_CASH_ACCOUNT}) + mode_of_payment.save(ignore_permissions=True) + + def setUp(self): + super().setUp() + self.addCleanup(frappe.set_user, "Administrator") + self.patch_razorpay_transport() + + self.item_code = self.create_item() + self.customer = self.create_customer() + self.contact_email = self.create_contact(self.customer) + self.quotation = self.create_cart_quotation() + self.shopper = self.create_shopper_user(self.contact_email) + + # -- gateway driver ----------------------------------------------------------------------------- + # Recordings come back in Razorpay's own request shapes; a stub must keep producing the same ones. + + def patch_razorpay_transport(self): + FakeRazorpay.reset() + for name, fake in (("make_post_request", FakeRazorpay.post), ("make_get_request", FakeRazorpay.get)): + transport_patch = patch.object(razorpay_gateway_settings, name, fake) + transport_patch.start() + self.addCleanup(transport_patch.stop) + + def created_links(self) -> list[dict]: + return FakeRazorpay.created_links + + def checkout_url(self, link_id: str) -> str: + return FakeRazorpay.links[link_id]["short_url"] + + def link_status(self, link_id: str) -> str: + return FakeRazorpay.links[link_id]["status"] + + def shopper_pays(self, link_id: str) -> str: + """The shopper completes the hosted page: Razorpay captures a payment and closes the link.""" + FakeRazorpay.links[link_id]["status"] = "paid" + return FakeRazorpay.add_payment(link_id, "captured") + + def refunds_sent(self) -> list[dict]: + return FakeRazorpay.created_refunds + + def deliver_paid_webhook(self, link_id: str) -> dict: + payload = build_payment_link_paid_event(link_id) + builder = EnvironBuilder( + method="POST", path="/api/method/bwh_payments.bwh_payments.webhook.handle", data=payload + ) + builder.headers.extend( + { + "Content-Type": "application/json", + "X-Razorpay-Event-Id": f"evt_{link_id}", + "X-Razorpay-Signature": sign_razorpay_payload(payload, RAZORPAY_WEBHOOK_SECRET), + } + ) + environ = builder.get_environ() + environ["QUERY_STRING"] = f"gateway={RAZORPAY_GATEWAY}" + + for attr in ("request", "request_ip", "response"): + self.addCleanup(setattr, frappe.local, attr, getattr(frappe.local, attr, None)) + frappe.local.request = Request(environ) + # Set by the WSGI handler in production; the webhook's rate limiter keys on it. + frappe.local.request_ip = "127.0.0.1" + frappe.local.response = frappe._dict() + return webhook.handle() + + # -- journey helpers ---------------------------------------------------------------------------- + + def open_razorpay_checkout(self): + # Lower case on purpose: the storefront sends whatever the shopper's radio button carried. + checkout = open_checkout(self.quotation, "razorpay") + return frappe.get_doc("Gateway Payment Request", {"order_url": checkout["order_url"]}) + + # -- tests -------------------------------------------------------------------------------------- + + def test_checkout_opens_a_razorpay_payment_link_for_the_cart_total(self): + payment_request = self.open_razorpay_checkout() + + self.assertEqual(payment_request.gateway, RAZORPAY_GATEWAY) + self.assertEqual(payment_request.status, "Pending") + self.assertEqual(payment_request.ref_doctype, "Quotation") + self.assertEqual(payment_request.ref_docname, self.quotation.name) + self.quotation.reload() + self.assertEqual(flt(payment_request.amount), get_charge_amount(self.quotation)) + + [link] = self.created_links() + self.assertEqual( + link["amount"], to_minor_units(payment_request.amount, payment_request.currency_code) + ) + self.assertEqual(link["currency"], payment_request.currency_code.upper()) + self.assertEqual(link["reference_id"], payment_request.name) + self.assertIn(f"reference_id={payment_request.name}", link["callback_url"]) + self.assertEqual(link["customer"]["email"], self.contact_email) + self.assertEqual(payment_request.order_url, self.checkout_url(payment_request.order_ref)) + + def test_a_paid_webhook_places_the_order_and_books_the_payment(self): + payment_request = self.open_razorpay_checkout() + self.shopper_pays(payment_request.order_ref) + + response = self.deliver_paid_webhook(payment_request.order_ref) + + self.assertEqual(response, {"status": "ok"}) + payment_request.reload() + self.assertEqual(payment_request.status, "Paid") + + sales_orders = self.submitted_sales_orders() + self.assertEqual(len(sales_orders), 1) + self.assertEqual(payment_request.ref_doctype, "Sales Order") + self.assertEqual(payment_request.ref_docname, sales_orders[0]) + + sales_invoices = self.submitted_sales_invoices(sales_orders[0]) + self.assertEqual(len(sales_invoices), 1) + payment_entries = self.submitted_payment_entries(sales_invoices[0]) + self.assertEqual(len(payment_entries), 1) + payment_entry = frappe.db.get_value( + "Payment Entry", + payment_entries[0], + ["reference_no", "mode_of_payment", "paid_amount"], + as_dict=True, + ) + self.assertEqual(payment_entry.reference_no, payment_request.order_ref) + self.assertEqual(payment_entry.mode_of_payment, RAZORPAY_GATEWAY) + self.assertEqual(flt(payment_entry.paid_amount), flt(payment_request.amount)) + + def test_a_replayed_webhook_does_not_place_a_second_order(self): + payment_request = self.open_razorpay_checkout() + self.shopper_pays(payment_request.order_ref) + + self.deliver_paid_webhook(payment_request.order_ref) + self.deliver_paid_webhook(payment_request.order_ref) + + self.assertEqual(len(self.submitted_sales_orders()), 1) + self.assertEqual(frappe.db.count("Sales Order", {"customer": self.customer}), 1) + + def test_the_return_page_confirms_a_paid_link_without_a_webhook(self): + payment_request = self.open_razorpay_checkout() + self.shopper_pays(payment_request.order_ref) + + frappe.set_user(self.shopper) + # Razorpay's callback lands on the confirmation page carrying our reference_id. + result = confirm_payment(payment_request.name) + + self.assertEqual(result["status"], "Paid") + sales_orders = self.submitted_sales_orders() + self.assertEqual(len(sales_orders), 1) + self.assertEqual(result["order_name"], sales_orders[0]) + + def test_the_return_page_leaves_an_unpaid_link_pending(self): + payment_request = self.open_razorpay_checkout() + + frappe.set_user(self.shopper) + result = confirm_payment(payment_request.name) + + self.assertEqual(result["status"], "Pending") + self.assertEqual(self.submitted_sales_orders(), []) + + def test_editing_the_cart_cancels_an_abandoned_payment_link(self): + payment_request = self.open_razorpay_checkout() + + validate_cart_is_not_in_checkout(self.quotation.name) + + payment_request.reload() + self.assertEqual(payment_request.status, "Cancelled") + self.assertEqual(self.link_status(payment_request.order_ref), "cancelled") + + def test_a_paid_link_keeps_the_cart_locked(self): + payment_request = self.open_razorpay_checkout() + self.shopper_pays(payment_request.order_ref) + + with self.assertRaises(frappe.ValidationError) as raised: + validate_cart_is_not_in_checkout(self.quotation.name) + + self.assertIn("already been paid", str(raised.exception)) + self.assertEqual(self.link_status(payment_request.order_ref), "paid") + + def test_the_sweep_places_an_order_the_shopper_never_came_back_for(self): + payment_request = self.open_razorpay_checkout() + self.shopper_pays(payment_request.order_ref) + frappe.db.set_value( + "Gateway Payment Request", + payment_request.name, + "creation", + add_to_date(now_datetime(), minutes=-30), + update_modified=False, + ) + + sync_pending_gateway_payments() + + self.assertEqual( + frappe.db.get_value("Gateway Payment Request", payment_request.name, "status"), "Paid" + ) + self.assertEqual(len(self.submitted_sales_orders()), 1) + + def test_a_refund_payment_entry_refunds_the_captured_razorpay_payment(self): + payment_request = self.open_razorpay_checkout() + payment_id = self.shopper_pays(payment_request.order_ref) + self.deliver_paid_webhook(payment_request.order_ref) + + self.payment_request = payment_request # the borrowed helper refunds against self.payment_request + self.create_refund_payment_entry(50, mode_of_payment=RAZORPAY_GATEWAY) + + self.assertEqual( + self.refunds_sent(), + [{"payment_id": payment_id, "amount": to_minor_units(50, payment_request.currency_code)}], + ) + payment_request.reload() + self.assertEqual(flt(payment_request.refund_amount), 50.0) + self.assertEqual(payment_request.status, "Partially Refunded") + self.assertTrue(payment_request.refund_id.startswith("rfnd_")) From 772a29b013caccd5f8b06497d7415dd45d44a181 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Wed, 7 Oct 2026 11:39:25 +0530 Subject: [PATCH 2/3] test: drive the Razorpay journey through the bwh_payments stub Only imports and the gateway driver helpers change; every test body is untouched, which is the parity check for the bwh_payments refactor. --- commera/tests/test_razorpay_checkout.py | 36 ++++++++----------------- 1 file changed, 11 insertions(+), 25 deletions(-) diff --git a/commera/tests/test_razorpay_checkout.py b/commera/tests/test_razorpay_checkout.py index ed288a25..00cf3d82 100644 --- a/commera/tests/test_razorpay_checkout.py +++ b/commera/tests/test_razorpay_checkout.py @@ -4,18 +4,13 @@ import frappe from bwh_payments.bwh_payments import webhook -from bwh_payments.bwh_payments.doctype.razorpay_gateway_settings import razorpay_gateway_settings from bwh_payments.bwh_payments.doctype.razorpay_gateway_settings.test_razorpay_gateway_settings import ( RAZORPAY_GATEWAY, RAZORPAY_WEBHOOK_SECRET, configure_razorpay_gateway, ) from bwh_payments.currency import to_minor_units -from bwh_payments.tests.fake_razorpay import ( - FakeRazorpay, - build_payment_link_paid_event, - sign_razorpay_payload, -) +from bwh_payments.services.razorpay.stub import StubRazorpayClient, build_payment_link_event, sign_payload from frappe.tests import IntegrationTestCase from frappe.utils import add_to_date, getdate, now_datetime from frappe.utils.data import flt @@ -52,8 +47,8 @@ class TestRazorpayCheckout(IntegrationTestCase): @classmethod def setUpClass(cls): super().setUpClass() - # Every Razorpay call writes an Integration Request through create_request_log, which commits. The - # test case only rolls back at class end, so without this the whole journey would land on the site. + # Webhook and refund logging go through create_request_log, which commits. The test case only rolls + # back at class end, so without this the whole journey would land on the site. commit_patch = patch.object(frappe.db, "commit") commit_patch.start() cls.addClassCleanup(cls.discard_class_writes, commit_patch) @@ -91,7 +86,7 @@ def ensure_mode_of_payment(cls): def setUp(self): super().setUp() self.addCleanup(frappe.set_user, "Administrator") - self.patch_razorpay_transport() + StubRazorpayClient.reset() self.item_code = self.create_item() self.customer = self.create_customer() @@ -102,32 +97,23 @@ def setUp(self): # -- gateway driver ----------------------------------------------------------------------------- # Recordings come back in Razorpay's own request shapes; a stub must keep producing the same ones. - def patch_razorpay_transport(self): - FakeRazorpay.reset() - for name, fake in (("make_post_request", FakeRazorpay.post), ("make_get_request", FakeRazorpay.get)): - transport_patch = patch.object(razorpay_gateway_settings, name, fake) - transport_patch.start() - self.addCleanup(transport_patch.stop) - def created_links(self) -> list[dict]: - return FakeRazorpay.created_links + return StubRazorpayClient.created_links def checkout_url(self, link_id: str) -> str: - return FakeRazorpay.links[link_id]["short_url"] + return StubRazorpayClient.links[link_id].short_url def link_status(self, link_id: str) -> str: - return FakeRazorpay.links[link_id]["status"] + return StubRazorpayClient.links[link_id].status def shopper_pays(self, link_id: str) -> str: - """The shopper completes the hosted page: Razorpay captures a payment and closes the link.""" - FakeRazorpay.links[link_id]["status"] = "paid" - return FakeRazorpay.add_payment(link_id, "captured") + return StubRazorpayClient.pay(link_id) def refunds_sent(self) -> list[dict]: - return FakeRazorpay.created_refunds + return StubRazorpayClient.created_refunds def deliver_paid_webhook(self, link_id: str) -> dict: - payload = build_payment_link_paid_event(link_id) + payload = build_payment_link_event(link_id) builder = EnvironBuilder( method="POST", path="/api/method/bwh_payments.bwh_payments.webhook.handle", data=payload ) @@ -135,7 +121,7 @@ def deliver_paid_webhook(self, link_id: str) -> dict: { "Content-Type": "application/json", "X-Razorpay-Event-Id": f"evt_{link_id}", - "X-Razorpay-Signature": sign_razorpay_payload(payload, RAZORPAY_WEBHOOK_SECRET), + "X-Razorpay-Signature": sign_payload(payload, RAZORPAY_WEBHOOK_SECRET), } ) environ = builder.get_environ() From 49fb92b6c09851f01af86fd8d65fbd8e3d7ca7ed Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Wed, 7 Oct 2026 11:42:34 +0530 Subject: [PATCH 3/3] test: drive the Stripe payment hooks through the bwh_payments stub bwh_payments dropped tests/fake_stripe.py; the swap is one for one and no assertion changes. --- commera/tests/test_payment_hooks.py | 37 ++++++++++++----------------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/commera/tests/test_payment_hooks.py b/commera/tests/test_payment_hooks.py index 6809de63..b42d46d1 100644 --- a/commera/tests/test_payment_hooks.py +++ b/commera/tests/test_payment_hooks.py @@ -8,8 +8,7 @@ configure_stripe_gateway, remove_stripe_gateway, ) -from bwh_payments.bwh_payments.doctype.stripe_gateway_settings import stripe_gateway_settings -from bwh_payments.tests.fake_stripe import FakeStripeClient +from bwh_payments.services.stripe.stub import StubStripeClient from frappe.tests import IntegrationTestCase from frappe.utils import add_to_date, get_year_ending, get_year_start, getdate, now_datetime from frappe.utils.data import flt @@ -54,12 +53,9 @@ def tearDownClass(cls): frappe.db.commit() def setUp(self): - FakeStripeClient.reset() + # bwh_payments swaps in its in-memory Stripe under tests; everything else here is real. + StubStripeClient.reset() self.addCleanup(frappe.set_user, frappe.session.user) - # The gateway transport is the only true external boundary here; everything else is real. - stripe_client_patch = patch.object(stripe_gateway_settings.stripe, "StripeClient", FakeStripeClient) - stripe_client_patch.start() - self.addCleanup(stripe_client_patch.stop) self.item_code = self.create_item() self.customer = self.create_customer() @@ -215,7 +211,7 @@ def create_pending_payment_request(self, quotation, amount=None): "customer_email": self.contact_email, } ).insert(ignore_permissions=True) - FakeStripeClient.register_paid_session(payment_request.order_ref, CURRENCY.lower()) + StubStripeClient.pay(payment_request.order_ref) return payment_request def create_paid_payment_request(self, quotation): @@ -233,9 +229,7 @@ def create_paid_payment_request(self, quotation): } ).insert(ignore_permissions=True) - FakeStripeClient.register_paid_session( - payment_request.order_ref, payment_request.currency_code.lower() - ) + StubStripeClient.pay(payment_request.order_ref) # db_set, not save(), so flipping to Paid does not itself fire the hook under test. payment_request.db_set("status", "Paid", update_modified=False) payment_request.reload() @@ -325,7 +319,7 @@ def test_saving_the_request_as_paid_fires_the_hook_through_doc_events(self): "customer_email": self.contact_email, } ).insert(ignore_permissions=True) - FakeStripeClient.register_paid_session(payment_request.order_ref, CURRENCY.lower()) + StubStripeClient.pay(payment_request.order_ref) payment_request.status = "Paid" payment_request.save(ignore_permissions=True) @@ -363,10 +357,10 @@ def create_refund_payment_entry(self, amount, mode_of_payment=GATEWAY): def test_an_amended_refund_payment_entry_does_not_refund_a_second_time(self): """Cancel+amend re-issues the entry as `-1`, which the doc.name dedupe key never matched.""" on_payment_request_update(self.payment_request) - FakeStripeClient.created_refunds.clear() + StubStripeClient.created_refunds.clear() payment_entry = self.create_refund_payment_entry(50) - self.assertEqual(len(FakeStripeClient.created_refunds), 1) + self.assertEqual(len(StubStripeClient.created_refunds), 1) payment_entry.cancel() amended = frappe.copy_doc(payment_entry) @@ -377,7 +371,7 @@ def test_an_amended_refund_payment_entry_does_not_refund_a_second_time(self): amended.submit() self.assertNotEqual(amended.name, payment_entry.name) - self.assertEqual(len(FakeStripeClient.created_refunds), 1) + self.assertEqual(len(StubStripeClient.created_refunds), 1) self.assertEqual( flt(frappe.db.get_value("Gateway Payment Request", self.payment_request.name, "refund_amount")), 50.0, @@ -386,11 +380,11 @@ def test_an_amended_refund_payment_entry_does_not_refund_a_second_time(self): def test_a_payment_entry_with_no_mode_of_payment_never_reaches_the_gateway(self): """A blank mode plus a coincidentally matching reference_no used to refund real money.""" on_payment_request_update(self.payment_request) - FakeStripeClient.created_refunds.clear() + StubStripeClient.created_refunds.clear() self.create_refund_payment_entry(50, mode_of_payment=None) - self.assertEqual(FakeStripeClient.created_refunds, []) + self.assertEqual(StubStripeClient.created_refunds, []) self.assertEqual( flt(frappe.db.get_value("Gateway Payment Request", self.payment_request.name, "refund_amount")), 0.0, @@ -475,9 +469,8 @@ def test_the_payment_request_is_repointed_at_the_sales_order(self): def create_abandoned_payment_request(self, quotation): payment_request = self.create_pending_payment_request(quotation) - FakeStripeClient.sessions[payment_request.order_ref].update( - {"payment_status": "unpaid", "status": "open", "payment_intent": None} - ) + session = StubStripeClient.sessions[payment_request.order_ref] + session.payment_status, session.status, session.payment_intent = "unpaid", "open", None return payment_request def test_an_abandoned_checkout_releases_the_cart_instead_of_refusing_it(self): @@ -488,7 +481,7 @@ def test_an_abandoned_checkout_releases_the_cart_instead_of_refusing_it(self): payment_request.reload() self.assertEqual(payment_request.status, "Cancelled") - self.assertEqual(FakeStripeClient.sessions[payment_request.order_ref]["status"], "expired") + self.assertEqual(StubStripeClient.sessions[payment_request.order_ref].status, "expired") def test_a_cart_whose_payment_the_gateway_still_honours_stays_locked(self): quotation = self.create_cart_quotation() @@ -572,7 +565,7 @@ def test_the_sweep_leaves_a_request_older_than_the_lookback_alone(self): def test_one_unreachable_gateway_session_does_not_strand_the_rest_of_the_sweep(self): unreachable_quotation = self.create_cart_quotation() unreachable_request = self.create_pending_payment_request(unreachable_quotation) - # The fake transport raises for a session it never issued, as an expired reference does. + # The stub raises for a session it never issued, as Stripe does for an expired reference. frappe.db.set_value("Gateway Payment Request", unreachable_request.name, "order_ref", "cs_test_gone") # Strictly older, so the sweep's oldest-first ordering reaches it before the healthy one. self.age_payment_request(unreachable_request, minutes=60)