From 90eb56ed8316101ef27f0e2e3ce581b6ed9f049c Mon Sep 17 00:00:00 2001 From: Tarunkumar0601 Date: Fri, 26 Jun 2026 12:40:32 +0530 Subject: [PATCH 1/3] fix(stripe): remove unknown column payment_plan in field list --- payments/templates/pages/stripe_checkout.py | 22 +++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/payments/templates/pages/stripe_checkout.py b/payments/templates/pages/stripe_checkout.py index 7f4a30e0b..e858504cf 100644 --- a/payments/templates/pages/stripe_checkout.py +++ b/payments/templates/pages/stripe_checkout.py @@ -41,13 +41,23 @@ def get_context(context): context["amount"] = fmt_money(amount=context["amount"], currency=context["currency"]) if is_a_subscription(context.reference_doctype, context.reference_docname): - payment_plan = frappe.db.get_value( - context.reference_doctype, context.reference_docname, "payment_plan" + payment_plans = frappe.db.get_all( + "Subscription Plan Detail", + filters={"parent": context.reference_docname, "parenttype": context.reference_doctype}, + fields=["plan"], + limit=1 ) - recurrence = frappe.db.get_value("Payment Plan", payment_plan, "recurrence") - - context["amount"] = context["amount"] + " " + _(recurrence) - + if payment_plans: + billing_interval, billing_interval_count = frappe.db.get_value( + "Subscription Plan", payment_plans[0].plan, ["billing_interval", "billing_interval_count"] + ) + billing_interval_count = cint(billing_interval_count) or 1 + if billing_interval_count == 1: + recurrence = _("per {0}").format(_(billing_interval)) + else: + recurrence = _("every {0} {1}s").format(billing_interval_count, _(billing_interval)) + + context["amount"] = context["amount"] + " " + recurrence else: frappe.redirect_to_message( _("Some information is missing"), From 9e1844565884aa882f84d144ce0613320f3e9125 Mon Sep 17 00:00:00 2001 From: Tarunkumar0601 Date: Mon, 29 Jun 2026 13:11:14 +0530 Subject: [PATCH 2/3] feat(stripe): shared infrastructure for the Stripe refactor --- payments/hooks.py | 15 +-- payments/patches.txt | 4 + payments/patches/__init__.py | 0 payments/patches/add_stripe_custom_fields.py | 13 ++ .../stripe_settings/stripe_settings.json | 52 +++++++- .../stripe_subscription_sync.py | 116 ++++++++++++++++++ payments/payment_gateways/stripe_utils.py | 105 ++++++++++++++++ payments/utils/utils.py | 57 ++++++++- 8 files changed, 353 insertions(+), 9 deletions(-) create mode 100644 payments/patches/__init__.py create mode 100644 payments/patches/add_stripe_custom_fields.py create mode 100644 payments/payment_gateways/stripe_subscription_sync.py create mode 100644 payments/payment_gateways/stripe_utils.py diff --git a/payments/hooks.py b/payments/hooks.py index 404bc889a..283ffb0f6 100644 --- a/payments/hooks.py +++ b/payments/hooks.py @@ -100,13 +100,14 @@ # --------------- # Hook on document methods and events -# doc_events = { -# "*": { -# "on_update": "method", -# "on_cancel": "method", -# "on_trash": "method" -# } -# } +doc_events = { + "Subscription Plan": { + # on_update (post-save), not validate: the Stripe sync makes live API calls, + # which must never run inside the save transaction where a Stripe outage + # would block/roll back the user's save. + "on_update": "payments.payment_gateways.stripe_subscription_sync.sync_stripe_price", + }, +} # Scheduled Tasks # --------------- diff --git a/payments/patches.txt b/payments/patches.txt index e69de29bb..a2723a815 100644 --- a/payments/patches.txt +++ b/payments/patches.txt @@ -0,0 +1,4 @@ +[pre_model_sync] + +[post_model_sync] +payments.patches.add_stripe_custom_fields diff --git a/payments/patches/__init__.py b/payments/patches/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/payments/patches/add_stripe_custom_fields.py b/payments/patches/add_stripe_custom_fields.py new file mode 100644 index 000000000..b22abce5f --- /dev/null +++ b/payments/patches/add_stripe_custom_fields.py @@ -0,0 +1,13 @@ +# Copyright (c) Frappe Technologies Pvt. Ltd. and contributors +# License: MIT. See LICENSE +# +# Installs the Stripe integration custom fields (Customer.stripe_customer_id, +# Subscription.stripe_subscription_id / stripe_customer_id, +# Payment Entry.stripe_payment_intent) on existing sites. make_custom_fields() +# is idempotent, so re-running is safe. + +from payments.utils import make_custom_fields + + +def execute(): + make_custom_fields() diff --git a/payments/payment_gateways/doctype/stripe_settings/stripe_settings.json b/payments/payment_gateways/doctype/stripe_settings/stripe_settings.json index b1bd1c3e6..1ac5fad8a 100644 --- a/payments/payment_gateways/doctype/stripe_settings/stripe_settings.json +++ b/payments/payment_gateways/doctype/stripe_settings/stripe_settings.json @@ -265,6 +265,56 @@ "set_only_once": 0, "translatable": 0, "unique": 0 + }, + { + "fieldname": "configuration_section", + "fieldtype": "Section Break", + "label": "Configuration" + }, + { + "default": "Hosted Checkout", + "description": "Hosted Checkout redirects to a Stripe-hosted page (recommended, SCA/3DS ready). Embedded Elements renders the card form on this site.", + "fieldname": "checkout_mode", + "fieldtype": "Select", + "label": "Checkout Mode", + "options": "Hosted Checkout\nEmbedded Elements" + }, + { + "default": "0", + "description": "If enabled, a Subscription Plan's Stripe price is auto-synced from its ERPNext cost on save.", + "fieldname": "sync_subscription_price", + "fieldtype": "Check", + "label": "Sync Subscription Plan Price to Stripe" + }, + { + "fieldname": "configuration_cb", + "fieldtype": "Column Break" + }, + { + "default": "Bill From Cycle One", + "description": "Bill From Cycle One: Stripe bills price×quantity immediately as the first invoice. Charge Now + Defer First Cycle: charge the Payment Request grand total now and defer the subscription's first billing to the next cycle.", + "fieldname": "subscription_billing_model", + "fieldtype": "Select", + "label": "Subscription Billing Model", + "options": "Bill From Cycle One\nCharge Now + Defer First Cycle" + }, + { + "fieldname": "webhooks_section", + "fieldtype": "Section Break", + "label": "Webhooks" + }, + { + "description": "Stripe webhook signing secret (whsec_...). Required for the webhook endpoint to verify and process events.", + "fieldname": "webhook_secret", + "fieldtype": "Password", + "label": "Webhook Signing Secret" + }, + { + "description": "Point a Stripe webhook endpoint at this URL (copy into the Stripe dashboard).", + "fieldname": "webhook_endpoint", + "fieldtype": "Data", + "label": "Webhook URL", + "read_only": 1 } ], "has_web_view": 0, @@ -277,7 +327,7 @@ "issingle": 0, "istable": 0, "max_attachments": 0, - "modified": "2022-07-24 13:32:14.429916", + "modified": "2026-06-26 00:00:00.000000", "modified_by": "Administrator", "module": "Payment Gateways", "name": "Stripe Settings", diff --git a/payments/payment_gateways/stripe_subscription_sync.py b/payments/payment_gateways/stripe_subscription_sync.py new file mode 100644 index 000000000..e9b3fcb46 --- /dev/null +++ b/payments/payment_gateways/stripe_subscription_sync.py @@ -0,0 +1,116 @@ +# Copyright (c) Frappe Technologies Pvt. Ltd. and contributors +# Keeps a Subscription Plan's product_price_id in sync with its ERPNext cost. + +import frappe +from frappe import _ + +from payments.payment_gateways.stripe_utils import ( + get_stripe_settings_for_gateway, + to_minor_units, +) + +INTERVAL_MAP = {"Day": "day", "Week": "week", "Month": "month", "Year": "year"} + + +def sync_stripe_price(doc, method=None): + """doc_event on Subscription Plan (erpnext) — owned by the payments app.""" + if doc.price_determination not in ("Fixed Rate", "Monthly Rate", "Based On Price List"): + return + if not doc.payment_gateway: + return + + settings = get_stripe_settings_for_gateway(doc.payment_gateway) + if not settings: + return # plan is not on a Stripe gateway + + if not settings.get("sync_subscription_price"): + return # opt-in disabled on this Stripe account — keep the manual flow + + # Resolve per-unit recurring amount for Stripe + unit_cost = _plan_unit_cost(doc) + if not unit_cost: + if doc.price_determination == "Based On Price List": + frappe.msgprint( + _( + "No rate found in the plan's Price List for its Item, so the " + "Stripe price could not be synced." + ), + indicator="orange", + alert=True, + ) + return + + from payments.payment_gateways.stripe_utils import get_stripe_client + + client = get_stripe_client(settings) + + if doc.billing_interval not in INTERVAL_MAP: + return # no Stripe-supported recurring interval set yet — nothing to sync + + unit_amount = to_minor_units(unit_cost, doc.currency) + recurring = { + "interval": INTERVAL_MAP[doc.billing_interval], + "interval_count": doc.billing_interval_count, + } + + try: + product_id = None + if doc.product_price_id: + existing = client.prices.retrieve(doc.product_price_id) + if _matches(existing, unit_amount, doc.currency, recurring): + return # already in sync — no API write + product_id = existing.product # reuse same Product + client.prices.update(doc.product_price_id, {"active": False}) # archive (prices are immutable) + + if not product_id: + product_id = client.products.create( + {"name": doc.plan_name, "metadata": {"erpnext_plan": doc.name}} + ).id + + price = client.prices.create( + { + "product": product_id, + "unit_amount": unit_amount, + "currency": (doc.currency or "").lower(), + "recurring": recurring, + "metadata": {"erpnext_plan": doc.name}, + } + ) + # We run on_update (after the row is written), so persist directly. + doc.db_set("product_price_id", price.id, update_modified=False) + + except Exception: + frappe.log_error(frappe.get_traceback(), "Stripe price sync failed") + frappe.msgprint( + _("Could not sync this plan's price to Stripe; the Product Price ID may be stale. Please retry."), + indicator="orange", + alert=True, + ) + + +def _matches(price, unit_amount, currency, recurring): + return bool( + price.get("active") + and price.unit_amount == unit_amount + and (price.currency or "").upper() == (currency or "").upper() + and price.get("recurring") + and price.recurring.interval == recurring["interval"] + and price.recurring.interval_count == recurring["interval_count"] + ) + + +def _plan_unit_cost(doc): + """Per-unit recurring amount in the plan's currency (qty=1). + + Stripe stores a single unit price and multiplies by quantity at checkout, + so we always push the qty=1 rate. + """ + if doc.price_determination == "Based On Price List": + from payments.utils import erpnext_app_import_guard + + with erpnext_app_import_guard(): + from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate + + return get_plan_rate(doc.name, quantity=1) + # Fixed Rate / Monthly Rate carry a per-interval cost on the plan itself. + return doc.cost diff --git a/payments/payment_gateways/stripe_utils.py b/payments/payment_gateways/stripe_utils.py new file mode 100644 index 000000000..34daf4416 --- /dev/null +++ b/payments/payment_gateways/stripe_utils.py @@ -0,0 +1,105 @@ +# Copyright (c) Frappe Technologies Pvt. Ltd. and contributors +# License: MIT. See LICENSE +# +# Shared helpers for the Stripe integration. Everything that more than one +# Stripe module needs (the client, amount conversion, idempotency keys, the +# settings-resolution chain) lives here so the charge, subscription, sync and +# webhook code all agree on the same rules. + +import hashlib + +import frappe +from frappe.utils import flt + +# Pin the Stripe API version to the one bundled with stripe~=10.12 so behaviour +# does not drift if the account's default version is bumped in the dashboard. +STRIPE_API_VERSION = "2024-06-20" + +# Stripe takes amounts in the smallest currency unit (cents), except +# zero-decimal currencies which are already whole units. +# https://docs.stripe.com/currencies#zero-decimal +ZERO_DECIMAL_CURRENCIES = { + "BIF", + "CLP", + "DJF", + "GNF", + "JPY", + "KMF", + "KRW", + "MGA", + "PYG", + "RWF", + "UGX", + "VND", + "VUV", + "XAF", + "XOF", + "XPF", +} + + +def get_stripe_client(stripe_settings): + """Return a Stripe client bound to this Stripe Settings doc. + + stripe_settings may be a doc or the docname of a "Stripe Settings" record. + + A fresh stripe.StripeClient is built per call, carrying its own api key, + pinned api version and http client. This keeps the credentials isolated to + the caller: mutating module-level stripe.api_key / stripe.api_version races + across threads, so two concurrent requests for different Stripe accounts + could charge the wrong account. Route every Stripe call through this client. + """ + import stripe + + if isinstance(stripe_settings, str): + stripe_settings = frappe.get_doc("Stripe Settings", stripe_settings) + + return stripe.StripeClient( + stripe_settings.get_password("secret_key", raise_exception=False), + stripe_version=STRIPE_API_VERSION, + http_client=stripe.http_client.RequestsClient(), + ) + + +def to_minor_units(amount, currency): + """Convert a human amount to the integer Stripe expects (e.g. 12.50 USD -> 1250).""" + if (currency or "").upper() in ZERO_DECIMAL_CURRENCIES: + return int(round(flt(amount))) + return int(round(flt(amount) * 100)) + + +def from_minor_units(amount, currency): + """Inverse of to_minor_units (e.g. 1250 USD -> 12.50).""" + if (currency or "").upper() in ZERO_DECIMAL_CURRENCIES: + return flt(amount) + return flt(amount) / 100.0 + + +def idempotency_key(*parts): + """Deterministic Stripe idempotency key derived from ERPNext identifiers. + + Same inputs -> same key, so a retried create collapses to one Stripe object + (Stripe dedupes idempotency keys for 24h). Keep the inputs stable per logical + operation (e.g. the reference docname + amount), never a timestamp/random. + """ + raw = ":".join(str(p) for p in parts if p is not None) + return hashlib.sha256(raw.encode("utf-8")).hexdigest() + + +def get_stripe_settings_for_gateway(payment_gateway_account): + """Resolve a Payment Gateway Account name to its Stripe Settings doc. + + Walks the same chain as get_gateway_controller() in stripe_settings.py: + Payment Gateway Account -> Payment Gateway -> Stripe Settings + Returns None when the account is not backed by Stripe Settings. + """ + pg = frappe.db.get_value("Payment Gateway Account", payment_gateway_account, "payment_gateway") + if not pg: + return None + gw = frappe.db.get_value("Payment Gateway", pg, ["gateway_settings", "gateway_controller"], as_dict=True) + if not gw or gw.gateway_settings != "Stripe Settings": + return None + if not gw.gateway_controller: + # Gateway relies on the naming-convention fallback; + return None + return frappe.get_doc("Stripe Settings", gw.gateway_controller) diff --git a/payments/utils/utils.py b/payments/utils/utils.py index 64e79a99d..bc03d3393 100644 --- a/payments/utils/utils.py +++ b/payments/utils/utils.py @@ -155,13 +155,68 @@ def make_custom_fields(): "reqd": 1, "insert_after": "disabled", } - ] + ], + # Stripe integration links (see payments.payment_gateways.stripe_*) + "Customer": [ + { + "fieldname": "stripe_customer_id", + "fieldtype": "Data", + "label": "Stripe Customer ID", + "read_only": 1, + "no_copy": 1, + "print_hide": 1, + "insert_after": "default_currency", + } + ], + "Subscription": [ + { + "fieldname": "stripe_subscription_id", + "fieldtype": "Data", + "label": "Stripe Subscription ID", + "read_only": 1, + "no_copy": 1, + "print_hide": 1, + "insert_after": "status", + }, + { + "fieldname": "stripe_customer_id", + "fieldtype": "Data", + "label": "Stripe Customer ID", + "read_only": 1, + "no_copy": 1, + "print_hide": 1, + "insert_after": "stripe_subscription_id", + }, + ], + "Payment Entry": [ + { + "fieldname": "stripe_payment_intent", + "fieldtype": "Data", + "label": "Stripe Payment Intent", + "read_only": 1, + "no_copy": 1, + "print_hide": 1, + "insert_after": "reference_no", + } + ], } create_custom_fields(custom_fields) def delete_custom_fields(): + # Stripe integration custom fields on ERPNext doctypes + if "erpnext" in frappe.get_installed_apps(): + click.secho("* Uninstalling Stripe Custom Fields") + stripe_custom_fields = { + "Customer": ("stripe_customer_id",), + "Subscription": ("stripe_subscription_id", "stripe_customer_id"), + "Payment Entry": ("stripe_payment_intent",), + } + for dt, fieldnames in stripe_custom_fields.items(): + frappe.db.delete("Custom Field", {"dt": dt, "fieldname": ("in", fieldnames)}) + frappe.clear_cache(doctype=dt) + if not frappe.get_meta("Web Form").has_field("payments_tab"): return From df5747020475f54283a4fdcca780f996e214971b Mon Sep 17 00:00:00 2001 From: Tarunkumar0601 Date: Mon, 29 Jun 2026 13:28:59 +0530 Subject: [PATCH 3/3] feat(stripe): modern PaymentIntents checkout (Hosted + Embedded) --- .../stripe_settings/stripe_settings.py | 329 ++++++++++++++++-- .../stripe_settings/test_stripe_settings.py | 73 +++- .../templates/includes/stripe_checkout.js | 188 ++++++---- payments/templates/pages/payment_success.py | 15 +- payments/templates/pages/stripe_checkout.py | 82 ++++- 5 files changed, 581 insertions(+), 106 deletions(-) diff --git a/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py b/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py index a73695a46..77064ce6f 100644 --- a/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py +++ b/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py @@ -2,14 +2,20 @@ # License: MIT. See LICENSE from types import MappingProxyType -from urllib.parse import urlencode +from urllib.parse import quote, urlencode import frappe +import stripe from frappe import _ from frappe.integrations.utils import create_request_log, make_get_request from frappe.model.document import Document -from frappe.utils import call_hook_method, cint, flt, get_url +from frappe.utils import call_hook_method, flt, get_url +from payments.payment_gateways.stripe_utils import ( + get_stripe_client, + idempotency_key, + to_minor_units, +) from payments.utils import create_payment_gateway currency_wise_minimum_charge_amount = { @@ -169,7 +175,8 @@ def validate_stripe_credentails(self): ) } try: - make_get_request(url="https://api.stripe.com/v1/charges", headers=header) + # PaymentIntents is the current API; /v1/charges is the deprecated one. + make_get_request(url="https://api.stripe.com/v1/payment_intents?limit=1", headers=header) except Exception: frappe.throw(_("Seems Publishable Key or Secret Key is wrong !!!")) @@ -191,18 +198,28 @@ def validate_minimum_transaction_amount(self, currency, amount): ) def get_payment_url(self, **kwargs): + if (self.checkout_mode or "Hosted Checkout") == "Hosted Checkout": + return self.create_checkout_session(kwargs) + # Embedded Elements: render the on-site card form (PaymentElement). return get_url(f"./stripe_checkout?{urlencode(kwargs)}") - def create_request(self, data): - import stripe + def get_stripe_metadata(self, data=None, integration_request=None): + """Stripe metadata is string->string; drop empty values.""" + data = data or self.data + meta = { + "reference_doctype": data.get("reference_doctype"), + "reference_docname": data.get("reference_docname"), + "integration_request": integration_request, + } + return {k: str(v) for k, v in meta.items() if v is not None} + def create_request(self, data): self.data = frappe._dict(data) - stripe.api_key = self.get_password(fieldname="secret_key", raise_exception=False) - stripe.default_http_client = stripe.http_client.RequestsClient() + self.stripe = get_stripe_client(self) try: self.integration_request = create_request_log(self.data, service_name="Stripe") - return self.create_charge_on_stripe() + return self.create_payment_intent_on_stripe() except Exception: frappe.log_error(frappe.get_traceback()) @@ -216,42 +233,271 @@ def create_request(self, data): "status": 401, } - def create_charge_on_stripe(self): - import stripe + def create_payment_intent_for_checkout(self, data): + """Embedded Elements: create an unconfirmed PaymentIntent and return its secret.""" + client = get_stripe_client(self) + integration_request = create_request_log(data, service_name="Stripe") + intent = client.payment_intents.create( + { + "amount": to_minor_units(data.amount, data.currency), + "currency": (data.currency or "").lower(), + "description": data.get("description"), + "receipt_email": data.get("payer_email"), + "metadata": self.get_stripe_metadata(data=data, integration_request=integration_request.name), + "automatic_payment_methods": {"enabled": True, "allow_redirects": "never"}, + } + ) + integration_request.db_set("output", intent.id, update_modified=False) + return {"client_secret": intent.client_secret, "payment_intent": intent.id} + def create_payment_intent_on_stripe(self): + """Confirm a one-off payment via PaymentIntents (SCA/3DS ready).""" + client = self.stripe try: - charge = stripe.Charge.create( - amount=cint(flt(self.data.amount) * 100), - currency=self.data.currency, - source=self.data.stripe_token_id, - description=self.data.description, - receipt_email=self.data.payer_email, + pi_id = self.data.get("payment_intent") + if pi_id: + # Embedded flow: the PaymentElement already confirmed client-side. + intent = client.payment_intents.retrieve(pi_id) + self.assert_intent_matches_reference(intent) + else: + payment_method = self.data.get("payment_method") + if not payment_method and self.data.get("stripe_token_id"): + # Backward-compat: convert a legacy card token to a PaymentMethod. + payment_method = client.payment_methods.create( + {"type": "card", "card": {"token": self.data.get("stripe_token_id")}} + ).id + intent = client.payment_intents.create( + { + "amount": to_minor_units(self.data.amount, self.data.currency), + "currency": (self.data.currency or "").lower(), + "payment_method": payment_method, + "confirm": bool(payment_method), + "description": self.data.get("description"), + "receipt_email": self.data.get("payer_email"), + "metadata": self.get_stripe_metadata( + integration_request=self.integration_request.name + ), + "automatic_payment_methods": {"enabled": True, "allow_redirects": "never"}, + }, + # Key on the payment method so a network retry of the SAME attempt + # de-dupes, while a fresh attempt (new method) creates a new charge. + { + "idempotency_key": idempotency_key( + "pi", self.data.get("reference_docname"), self.data.get("amount"), payment_method + ) + }, + ) + return self.handle_payment_intent_status(intent) + + except stripe.error.CardError as e: + self.integration_request.db_set("status", "Failed", update_modified=False) + self.integration_request.db_set("error", frappe.as_json(e.json_body), update_modified=False) + return {"redirect_to": "payment-failed", "status": "Failed", "error": e.user_message} + + def handle_payment_intent_status(self, intent): + if intent.status == "succeeded": + self.integration_request.db_set("status", "Completed", update_modified=False) + self.integration_request.db_set("output", intent.id, update_modified=False) + self.flags.status_changed_to = "Completed" + return self.finalize_request() + + if intent.status in ("requires_action", "requires_confirmation"): + # Card needs extra authentication (3DS) — let the client finish. + return { + "requires_action": True, + "client_secret": intent.client_secret, + "payment_intent": intent.id, + "status": "Pending", + } + + if intent.status == "processing": + # Async method (e.g. bank transfer / delayed capture) still settling. + # Leave the request pending — the payment_intent.succeeded webhook + # finalizes it. Marking it Failed here would break these payments. + return {"redirect_to": _success_redirect(dict(intent.get("metadata") or {})), "status": "Pending"} + + # requires_payment_method / canceled + self.integration_request.db_set("status", "Failed", update_modified=False) + return {"redirect_to": "payment-failed", "status": "Failed"} + + def assert_intent_matches_reference(self, intent): + """Bind a client-supplied PaymentIntent to the order being settled. + + Without this a caller could present a PaymentIntent that succeeded for a + different (cheaper) order and settle this one for free. The reference in + the intent's own metadata must match the reference we are about to settle. + """ + meta = dict(intent.get("metadata") or {}) + if (meta.get("reference_doctype"), meta.get("reference_docname")) != ( + self.data.get("reference_doctype"), + self.data.get("reference_docname"), + ): + frappe.throw( + _("This payment does not belong to the order being settled."), frappe.PermissionError + ) + + def create_checkout_session(self, data): + """Hosted Checkout: build a Stripe-hosted payment page and return its URL.""" + # Local import avoids a circular import (stripe_checkout imports this module). + from payments.templates.pages.stripe_checkout import get_reference_amount, guard_payment_reference + + data = frappe._dict(data) + # Same guard + server-side amount the embedded path uses: never trust a + # client-supplied amount/currency or an unvalidated reference. + guard_payment_reference(data.reference_doctype, data.reference_docname) + data.amount, currency = get_reference_amount(data.reference_doctype, data.reference_docname) + if currency: + data.currency = currency + client = get_stripe_client(self) + integration_request = create_request_log(data, service_name="Stripe") + + success_url = get_url( + "/api/method/payments.payment_gateways.doctype.stripe_settings.stripe_settings.checkout_success" + + "?session_id={CHECKOUT_SESSION_ID}&gateway=" + + quote(self.name) + ) + metadata = self.get_stripe_metadata(data=data, integration_request=integration_request.name) + + session = client.checkout.sessions.create( + { + "mode": "payment", + "line_items": [ + { + "price_data": { + "currency": (data.currency or "").lower(), + "unit_amount": to_minor_units(data.amount, data.currency), + "product_data": { + "name": data.get("description") or data.get("title") or _("Payment") + }, + }, + "quantity": 1, + } + ], + "success_url": success_url, + "cancel_url": get_url("payment-failed"), + "client_reference_id": data.get("reference_docname"), + "customer_email": data.get("payer_email"), + "payment_intent_data": {"metadata": metadata}, + "metadata": metadata, + } + ) + integration_request.db_set("output", session.id, update_modified=False) + return session.url + + def claim_integration_request(self): + """Atomically claim the Integration Request for settlement. + + The Hosted Checkout return and the webhook can arrive concurrently. A + SELECT ... FOR UPDATE serialises them: the first flips the status to + Completed and settles; the second blocks, then sees Completed and backs + off — so finalize_request() runs exactly once (no duplicate Payment Entry). + Returns True only for the caller that won the claim. + """ + status = frappe.db.get_value( + "Integration Request", self.integration_request.name, "status", for_update=True + ) + if status == "Completed": + return False + self.integration_request.db_set("status", "Completed", update_modified=False) + return True + + def finalize_checkout_session(self, session_id): + """Confirm a Hosted Checkout session and run on_payment_authorized. + + Idempotent: callable from both the success redirect and the webhook. + """ + client = get_stripe_client(self) + session = client.checkout.sessions.retrieve(session_id) + metadata = dict(session.get("metadata") or {}) + + ir_name = metadata.get("integration_request") + if ir_name and frappe.db.exists("Integration Request", ir_name): + self.integration_request = frappe.get_doc("Integration Request", ir_name) + else: + ir = frappe.db.get_value("Integration Request", {"output": session_id}, "name") + self.integration_request = ( + frappe.get_doc("Integration Request", ir) + if ir + else create_request_log(session, service_name="Stripe") ) - if charge.captured is True: - self.integration_request.db_set("status", "Completed", update_modified=False) - self.flags.status_changed_to = "Completed" + if self.integration_request.status == "Completed": + return {"redirect_to": _success_redirect(metadata), "status": "Completed"} - else: - frappe.log_error(charge.failure_message, "Stripe Payment not completed") + if session.get("payment_status") not in ("paid", "no_payment_required"): + if session.get("status") == "complete": + return {"redirect_to": _success_redirect(metadata), "status": "Pending"} + return {"redirect_to": "payment-failed", "status": "Failed"} - except Exception: - frappe.log_error(frappe.get_traceback()) + if not self.claim_integration_request(): + # Lost the race to a concurrent webhook/redirect — already settled. + return {"redirect_to": _success_redirect(metadata), "status": "Completed"} + self.data = frappe._dict( + { + "reference_doctype": metadata.get("reference_doctype"), + "reference_docname": metadata.get("reference_docname"), + } + ) + self.integration_request.db_set("output", session.get("payment_intent"), update_modified=False) + self.flags.status_changed_to = "Completed" return self.finalize_request() + def create_charge_on_stripe(self): + # Deprecated: the Charges API is replaced by PaymentIntents. Kept as a + # thin shim so any external caller keeps working. + return self.create_payment_intent_on_stripe() + + def authorize_reference(self): + """Settle the paid reference document. + + Custom doctypes that still define the legacy ``on_payment_authorized`` hook + keep working. Newer ERPNext removed it from Payment Request, so a Payment + Request is settled explicitly (status -> Paid + Payment Entry). + """ + ref = frappe.get_doc(self.data.reference_doctype, self.data.reference_docname) + if hasattr(ref, "on_payment_authorized"): + return ref.run_method("on_payment_authorized", self.flags.status_changed_to) + if ref.doctype == "Payment Request": + self.settle_payment_request(ref) + return None + + def settle_payment_request(self, pr): + """Mark a submitted Payment Request paid and create its Payment Entry. + + Idempotent: skips if the request is already paid or a Payment Entry already + exists for the underlying invoice. The Payment Entry's submission is what + flips the Payment Request status to "Paid" (via ERPNext's PE -> PR sync). + """ + if pr.docstatus != 1 or pr.status == "Paid": + return + if pr.payment_channel == "Phone": + pr.db_set({"status": "Paid", "outstanding_amount": 0}) + return + + from payments.utils import erpnext_app_import_guard + + with erpnext_app_import_guard(): + from erpnext.accounts.doctype.payment_request.payment_request import ( + get_existing_payment_entry, + ) + + if pr.reference_name and get_existing_payment_entry(pr.reference_name): + return # the invoice is already settled by a Payment Entry + + pr.set_as_paid() + def finalize_request(self): redirect_to = self.data.get("redirect_to") or None redirect_message = self.data.get("redirect_message") or None status = self.integration_request.status + redirect_url = "payment-success" if self.flags.status_changed_to == "Completed": if self.data.reference_doctype and self.data.reference_docname: custom_redirect_to = None try: - custom_redirect_to = frappe.get_doc( - self.data.reference_doctype, self.data.reference_docname - ).run_method("on_payment_authorized", self.flags.status_changed_to) + custom_redirect_to = self.authorize_reference() except Exception: frappe.log_error(frappe.get_traceback()) @@ -280,3 +526,34 @@ def get_gateway_controller(doctype, docname, payment_gateway=None): reference_doc = frappe.get_doc(doctype, docname) payment_gateway = reference_doc.payment_gateway return frappe.db.get_value("Payment Gateway", payment_gateway, "gateway_controller") + + +def _success_redirect(metadata): + """payment-success URL carrying the reference, so the success page can load it.""" + dt = (metadata or {}).get("reference_doctype") + dn = (metadata or {}).get("reference_docname") + if dt and dn: + return f"payment-success?{urlencode({'doctype': dt, 'docname': dn})}" + return "payment-success" + + +@frappe.whitelist(allow_guest=True) +def checkout_success(session_id, gateway): + """Return landing for Hosted Checkout — verify the session, then redirect. + + The webhook (checkout.session.completed) is the authoritative backstop; + finalize_checkout_session is idempotent so running both is safe. + """ + result = {} + try: + settings = frappe.get_doc("Stripe Settings", gateway) + result = settings.finalize_checkout_session(session_id) or {} + frappe.db.commit() + except Exception: + frappe.log_error(frappe.get_traceback(), "Stripe checkout return failed") + + # Only land on the success page when settlement actually returned a result; + # a swallowed exception leaves result empty, so fail visibly instead. + redirect_url = result.get("redirect_to") or ("payment-success" if result else "payment-failed") + frappe.local.response["type"] = "redirect" + frappe.local.response["location"] = "/" + redirect_url.lstrip("/") diff --git a/payments/payment_gateways/doctype/stripe_settings/test_stripe_settings.py b/payments/payment_gateways/doctype/stripe_settings/test_stripe_settings.py index eed87bfca..07f2b3378 100644 --- a/payments/payment_gateways/doctype/stripe_settings/test_stripe_settings.py +++ b/payments/payment_gateways/doctype/stripe_settings/test_stripe_settings.py @@ -1,7 +1,74 @@ # Copyright (c) 2018, Frappe Technologies and Contributors # License: MIT. See LICENSE -import unittest +# +# Regression tests for the Stripe checkout money-path guards. Each test pins a +# previously-exploitable hole shut so it cannot silently reopen: +# - client-supplied amount (underpay) +# - arbitrary / unauthorised reference +# - PaymentIntent replay (settle order B with order A's payment) +# - duplicate settlement (webhook vs browser-return race) +# They use Frappe core doctypes only, so they run without ERPNext installed. +from unittest.mock import patch +import frappe +from frappe.tests.utils import FrappeTestCase -class TestStripeSettings(unittest.TestCase): - pass +from payments.templates.pages.stripe_checkout import get_reference_amount, guard_payment_reference + + +class TestStripeSettings(FrappeTestCase): + def test_guard_rejects_missing_or_unknown_reference(self): + """guard_payment_reference blocks arbitrary/absent references (PermissionError).""" + with self.assertRaises(frappe.PermissionError): + guard_payment_reference(None, None) + with self.assertRaises(frappe.PermissionError): + guard_payment_reference("ToDo", "does-not-exist-000") + + def test_get_reference_amount_uses_server_side_grand_total(self): + """Amount is read from the reference's grand_total, never from client input.""" + with ( + patch.object(frappe, "get_meta") as mock_meta, + patch.object(frappe.db, "get_value") as mock_get_value, + ): + mock_meta.return_value.has_field.side_effect = lambda f: f in {"grand_total", "currency"} + mock_get_value.return_value = frappe._dict({"grand_total": 100.0, "currency": "USD"}) + amount, currency = get_reference_amount("Payment Request", "PR-0001") + self.assertEqual(amount, 100.0) + self.assertEqual(currency, "USD") + + def test_get_reference_amount_rejects_amountless_doctype(self): + """A reference doctype with no payable amount field is rejected, not defaulted.""" + with self.assertRaises(frappe.ValidationError): + get_reference_amount("ToDo", "any") + + def test_assert_intent_matches_reference_blocks_replay(self): + """A PaymentIntent minted for another order cannot settle this one.""" + settings = frappe.new_doc("Stripe Settings") + settings.data = frappe._dict({"reference_doctype": "Payment Request", "reference_docname": "ORDER-B"}) + intent_for_other_order = { + "metadata": {"reference_doctype": "Payment Request", "reference_docname": "ORDER-A"} + } + with self.assertRaises(frappe.PermissionError): + settings.assert_intent_matches_reference(intent_for_other_order) + + # The intent minted for this very order is accepted (no raise). + intent_for_this_order = { + "metadata": {"reference_doctype": "Payment Request", "reference_docname": "ORDER-B"} + } + settings.assert_intent_matches_reference(intent_for_this_order) + + def test_claim_integration_request_settles_only_once(self): + """The webhook/redirect race can't double-settle: the claim is single-use.""" + ir = frappe.get_doc( + {"doctype": "Integration Request", "integration_request_service": "Stripe"} + ).insert(ignore_permissions=True) + + settings = frappe.new_doc("Stripe Settings") + settings.integration_request = ir + + # First caller wins the claim and flips the request to Completed. + self.assertTrue(settings.claim_integration_request()) + self.assertEqual(frappe.db.get_value("Integration Request", ir.name, "status"), "Completed") + + # A concurrent second caller (already Completed) must lose the claim. + self.assertFalse(settings.claim_integration_request()) diff --git a/payments/templates/includes/stripe_checkout.js b/payments/templates/includes/stripe_checkout.js index 7f37e96ba..ec986710d 100644 --- a/payments/templates/includes/stripe_checkout.js +++ b/payments/templates/includes/stripe_checkout.js @@ -1,86 +1,130 @@ -var stripe = Stripe("{{ publishable_key }}"); +// Embedded Elements checkout using PaymentIntents + PaymentElement. +// Flow: create an (unconfirmed) PaymentIntent on the server -> mount the +// PaymentElement with its client_secret -> confirm client-side (handles 3DS) +// -> hand the confirmed PaymentIntent id back to the server to finalize. -var elements = stripe.elements(); +var stripe = Stripe("{{ publishable_key }}"); -var style = { - base: { - color: '#32325d', - lineHeight: '18px', - fontFamily: '"Helvetica Neue", Helvetica, sans-serif', - fontSmoothing: 'antialiased', - fontSize: '16px', - '::placeholder': { - color: '#aab7c4' - } - }, - invalid: { - color: '#fa755a', - iconColor: '#fa755a' - } +var checkoutData = { + data: JSON.stringify({{ frappe.form_dict|json }}), + reference_doctype: {{ reference_doctype | tojson }}, + reference_docname: {{ reference_docname | tojson }}, + payment_gateway: {{ payment_gateway | tojson }} }; -var card = elements.create('card', { - hidePostalCode: true, - style: style -}); +var elements; +var paymentElement; +var clientSecret; -card.mount('#card-element'); +function showError(message) { + var displayError = document.getElementById('card-errors'); + if (displayError) { + displayError.textContent = message || ''; + } +} -function setOutcome(result) { +function setSubmitting(isSubmitting) { + if (isSubmitting) { + $('#submit').prop('disabled', true).html(__('Processing...')); + } else { + $('#submit').prop('disabled', false).html(__('Pay') + ' {{ amount }}'); + } +} - if (result.token) { - $('#submit').prop('disabled', true) - $('#submit').html(__('Processing...')) - frappe.call({ - method:"payments.templates.pages.stripe_checkout.make_payment", - freeze:true, - headers: {"X-Requested-With": "XMLHttpRequest"}, - args: { - "stripe_token_id": result.token.id, - "data": JSON.stringify({{ frappe.form_dict|json }}), - "reference_doctype": "{{ reference_doctype }}", - "reference_docname": "{{ reference_docname }}", - "payment_gateway": "{{ payment_gateway }}" - }, - callback: function(r) { - if (r.message.status == "Completed") { - $('#submit').hide() - $('.success').show() - setTimeout(function() { - window.location.href = r.message.redirect_to - }, 2000); - } else { - $('#submit').hide() - $('.error').show() - setTimeout(function() { - window.location.href = r.message.redirect_to - }, 2000); - } - } - }); +function redirectAfter(result) { + setTimeout(function () { + if (result && result.redirect_to) { + window.location.href = result.redirect_to; + } + }, 2000); +} - } else if (result.error) { - $('.error').html(result.error.message); - $('.error').show() - } +function mountPaymentElement() { + frappe.call({ + method: "payments.templates.pages.stripe_checkout.create_payment_intent", + freeze: true, + headers: { "X-Requested-With": "XMLHttpRequest" }, + args: { + data: checkoutData.data, + reference_doctype: checkoutData.reference_doctype, + reference_docname: checkoutData.reference_docname, + payment_gateway: checkoutData.payment_gateway + }, + callback: function (r) { + if (!r.message || !r.message.client_secret) { + showError(__('Could not initialise the payment. Please try again.')); + return; + } + clientSecret = r.message.client_secret; + elements = stripe.elements({ clientSecret: clientSecret }); + paymentElement = elements.create('payment', { + defaultValues: { + billingDetails: { + name: {{ payer_name | tojson }}, + email: {{ payer_email | tojson }} + } + } + }); + paymentElement.mount('#card-element'); + } + }); } -card.on('change', function(event) { - var displayError = document.getElementById('card-errors'); - if (event.error) { - displayError.textContent = event.error.message; - } else { - displayError.textContent = ''; +function confirmPayment() { + if (!elements || !clientSecret) { + showError(__('Payment is still initialising. Please wait a moment.')); + return; } -}); + setSubmitting(true); + stripe.confirmPayment({ + elements: elements, + redirect: 'if_required', + confirmParams: { + receipt_email: $('input[name=cardholder-email]').val() + } + }).then(function (result) { + if (result.error) { + showError(result.error.message); + $('.error').show(); + setSubmitting(false); + return; + } -frappe.ready(function() { - $('#submit').off("click").on("click", function(e) { - e.preventDefault(); - var extraDetails = { - name: $('input[name=cardholder-name]').val(), - email: $('input[name=cardholder-email]').val() + var intent = result.paymentIntent; + if (intent && (intent.status === 'succeeded' || intent.status === 'processing')) { + frappe.call({ + method: "payments.templates.pages.stripe_checkout.make_payment", + freeze: true, + headers: { "X-Requested-With": "XMLHttpRequest" }, + args: { + payment_intent: intent.id, + data: checkoutData.data, + reference_doctype: checkoutData.reference_doctype, + reference_docname: checkoutData.reference_docname, + payment_gateway: checkoutData.payment_gateway + }, + callback: function (r) { + var msg = r.message || {}; + $('#submit').hide(); + if (msg.status === "Completed") { + $('.success').show(); + } else { + $('.error').show(); + } + redirectAfter(msg); + } + }); + } else { + showError(__('The payment could not be completed.')); + setSubmitting(false); } - stripe.createToken(card, extraDetails).then(setOutcome); - }) + }); +} + +frappe.ready(function () { + mountPaymentElement(); + $('#submit').off("click").on("click", function (e) { + e.preventDefault(); + confirmPayment(); + }); }); diff --git a/payments/templates/pages/payment_success.py b/payments/templates/pages/payment_success.py index e2d1115b7..9c715d4ce 100644 --- a/payments/templates/pages/payment_success.py +++ b/payments/templates/pages/payment_success.py @@ -7,8 +7,19 @@ def get_context(context): - doc = frappe.get_doc(frappe.local.form_dict.doctype, frappe.local.form_dict.docname) - context.payment_message = "" + + doctype = frappe.local.form_dict.get("doctype") + docname = frappe.local.form_dict.get("docname") + if not doctype or not docname or not frappe.db.exists(doctype, docname): + return + + # Don't leak another entity's success message to a logged-in caller who has + # no read access. Guests (who just completed the payment) can't hold doc + # permissions, so the existence check above is their guard. + if frappe.session.user != "Guest" and not frappe.has_permission(doctype, "read", docname): + return + + doc = frappe.get_doc(doctype, docname) if hasattr(doc, "get_payment_success_message"): context.payment_message = doc.get_payment_success_message() diff --git a/payments/templates/pages/stripe_checkout.py b/payments/templates/pages/stripe_checkout.py index e858504cf..85ec60efc 100644 --- a/payments/templates/pages/stripe_checkout.py +++ b/payments/templates/pages/stripe_checkout.py @@ -45,7 +45,7 @@ def get_context(context): "Subscription Plan Detail", filters={"parent": context.reference_docname, "parenttype": context.reference_doctype}, fields=["plan"], - limit=1 + limit=1, ) if payment_plans: billing_interval, billing_interval_count = frappe.db.get_value( @@ -79,11 +79,87 @@ def get_header_image(doc, gateway_controller): return frappe.db.get_value("Stripe Settings", gateway_controller, "header_img") +def guard_payment_reference(reference_doctype, reference_docname): + """Guard the guest-exposed payment endpoints against arbitrary references. + + The reference must be a real document. Authenticated callers must also have + read access; guests legitimately cannot (Payment Request grants no Guest + permission — the checkout return settles as Administrator for the same + reason), so for them the existence check is the guard. + """ + if not ( + reference_doctype and reference_docname and frappe.db.exists(reference_doctype, reference_docname) + ): + frappe.throw(_("Invalid payment reference."), frappe.PermissionError) + if frappe.session.user != "Guest": + frappe.has_permission(reference_doctype, "read", reference_docname, throw=True) + + +def get_reference_amount(reference_doctype, reference_docname): + """Authoritative payable amount + currency, read server-side from the reference. + + Never trust a client-supplied amount: without this an attacker can post any + value (e.g. 0.01) and settle a full order for a token amount. Payment + Requests carry the payable total in `grand_total`. + """ + meta = frappe.get_meta(reference_doctype) + amount_field = "grand_total" if meta.has_field("grand_total") else "amount" + if not meta.has_field(amount_field): + frappe.throw(_("Cannot determine the payable amount for {0}.").format(reference_doctype)) + fields = [amount_field] + (["currency"] if meta.has_field("currency") else []) + row = frappe.db.get_value(reference_doctype, reference_docname, fields, as_dict=True) + return row.get(amount_field), row.get("currency") + + @frappe.whitelist(allow_guest=True) -def make_payment(stripe_token_id, data, reference_doctype=None, reference_docname=None, payment_gateway=None): +def create_payment_intent(data, reference_doctype=None, reference_docname=None, payment_gateway=None): + """Embedded Elements: create an unconfirmed PaymentIntent, return its client_secret. + + The PaymentElement confirms it client-side (handling 3DS); make_payment then + verifies it server-side. + """ + guard_payment_reference(reference_doctype, reference_docname) data = json.loads(data) + # Reference + amount/currency are authoritative server-side, never the client. + # Stamping the reference binds the PaymentIntent's metadata to this order so it + # can't later be replayed to settle a different one. + data["reference_doctype"] = reference_doctype + data["reference_docname"] = reference_docname + data["amount"], currency = get_reference_amount(reference_doctype, reference_docname) + if currency: + data["currency"] = currency + gateway_controller = get_gateway_controller(reference_doctype, reference_docname, payment_gateway) + settings = frappe.get_doc("Stripe Settings", gateway_controller) + result = settings.create_payment_intent_for_checkout(frappe._dict(data)) + frappe.db.commit() + return result - data.update({"stripe_token_id": stripe_token_id}) + +@frappe.whitelist(allow_guest=True) +def make_payment( + data, + reference_doctype=None, + reference_docname=None, + payment_gateway=None, + payment_intent=None, + stripe_token_id=None, +): + guard_payment_reference(reference_doctype, reference_docname) + data = json.loads(data) + # Reference + amount/currency are authoritative server-side, never the client. + # The reference is what gets settled and is cross-checked against the intent's + # metadata so a payment for one order can't settle another. + data["reference_doctype"] = reference_doctype + data["reference_docname"] = reference_docname + data["amount"], currency = get_reference_amount(reference_doctype, reference_docname) + if currency: + data["currency"] = currency + + if payment_intent: + data.update({"payment_intent": payment_intent}) + if stripe_token_id: + # Backward-compat with the pre-PaymentIntents checkout JS. + data.update({"stripe_token_id": stripe_token_id}) gateway_controller = get_gateway_controller(reference_doctype, reference_docname, payment_gateway)