From 90eb56ed8316101ef27f0e2e3ce581b6ed9f049c Mon Sep 17 00:00:00 2001 From: Tarunkumar0601 Date: Fri, 26 Jun 2026 12:40:32 +0530 Subject: [PATCH 1/8] 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/8] 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/8] 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) From 30e3095e97c0ebf03dff678396d414542b1b9968 Mon Sep 17 00:00:00 2001 From: Tarunkumar0601 Date: Mon, 29 Jun 2026 13:33:08 +0530 Subject: [PATCH 4/8] feat(stripe): reuse customers and save cards for off-session reuse --- .../stripe_settings/stripe_settings.py | 188 ++++++++++++++---- .../stripe_settings/test_stripe_settings.py | 96 ++++++++- .../stripe_subscription_sync.py | 3 +- payments/payment_gateways/stripe_utils.py | 63 +++++- .../templates/includes/stripe_checkout.js | 36 +++- payments/templates/pages/stripe_checkout.html | 6 + payments/templates/pages/stripe_checkout.py | 66 ++++-- 7 files changed, 405 insertions(+), 53 deletions(-) diff --git a/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py b/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py index 77064ce6f..073aed22d 100644 --- a/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py +++ b/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py @@ -1,6 +1,7 @@ # Copyright (c) 2017, Frappe Technologies and contributors # License: MIT. See LICENSE +import hmac from types import MappingProxyType from urllib.parse import quote, urlencode @@ -12,6 +13,7 @@ from frappe.utils import call_hook_method, flt, get_url from payments.payment_gateways.stripe_utils import ( + get_or_create_customer, get_stripe_client, idempotency_key, to_minor_units, @@ -213,11 +215,53 @@ def get_stripe_metadata(self, data=None, integration_request=None): } return {k: str(v) for k, v in meta.items() if v is not None} + def get_party_for_reference(self, data): + """Resolve the ERPNext Customer behind a payment's reference document.""" + dt, dn = data.get("reference_doctype"), data.get("reference_docname") + if not dt or not dn or not frappe.db.exists(dt, dn): + return None + if dt == "Payment Request": + row = frappe.db.get_value("Payment Request", dn, ["party_type", "party"], as_dict=True) + if row and row.party_type == "Customer": + return row.party + return None + if frappe.get_meta(dt).has_field("customer"): + return frappe.db.get_value(dt, dn, "customer") + return None + + def resolve_stripe_customer(self, client, data): + """Reusable Stripe customer id for this payment's party (saves the card for reuse).""" + party = self.get_party_for_reference(data) + return get_or_create_customer( + client, + customer=party, + email=data.get("payer_email"), + name=data.get("payer_name") or party, + ) + + def create_setup_intent_for_card(self, data): + """SetupIntent to save a card off-session without charging (for later reuse).""" + data = frappe._dict(data) + client = get_stripe_client(self) + customer_id = self.resolve_stripe_customer(client, data) + intent = client.setup_intents.create( + { + "customer": customer_id, + "usage": "off_session", + "metadata": self.get_stripe_metadata(data=data), + } + ) + return {"client_secret": intent.client_secret, "setup_intent": intent.id, "customer": customer_id} + def create_request(self, data): self.data = frappe._dict(data) self.stripe = get_stripe_client(self) try: + if self.data.get("payment_intent"): + # Embedded flow already confirmed client-side; reuse the stamped Integration Request. + return self.finalize_payment_intent_by_id(self.data.get("payment_intent")) + self.integration_request = create_request_log(self.data, service_name="Stripe") return self.create_payment_intent_on_stripe() @@ -237,12 +281,14 @@ 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") + customer_id = self.resolve_stripe_customer(client, data) 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"), + "customer": customer_id, "metadata": self.get_stripe_metadata(data=data, integration_request=integration_request.name), "automatic_payment_methods": {"enabled": True, "allow_redirects": "never"}, } @@ -254,39 +300,36 @@ def create_payment_intent_on_stripe(self): """Confirm a one-off payment via PaymentIntents (SCA/3DS ready).""" client = self.stripe try: - 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 - ) - }, - ) + 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 + customer_id = self.resolve_stripe_customer(client, self.data) + params = { + "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"), + "customer": customer_id, + "metadata": self.get_stripe_metadata(integration_request=self.integration_request.name), + "automatic_payment_methods": {"enabled": True, "allow_redirects": "never"}, + } + # Store the card for off-session reuse only with explicit consent. + if self.data.get("save_card") and customer_id: + params["setup_future_usage"] = "off_session" + intent = client.payment_intents.create( + params, + # Idempotency key on the payment method dedupes a retry of the same attempt. + { + "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: @@ -336,6 +379,80 @@ def assert_intent_matches_reference(self, intent): _("This payment does not belong to the order being settled."), frappe.PermissionError ) + def finalize_payment_intent_by_id(self, pi_id): + """Retrieve a (client-confirmed) PaymentIntent and finalize idempotently.""" + client = getattr(self, "stripe", None) or get_stripe_client(self) + intent = client.payment_intents.retrieve(pi_id) + # Reject a PaymentIntent minted for a different order before settling. + self.assert_intent_matches_reference(intent) + + if intent.status == "succeeded": + return self.finalize_payment_intent(intent) + if intent.status in ("requires_action", "requires_confirmation"): + return { + "requires_action": True, + "client_secret": intent.client_secret, + "payment_intent": intent.id, + "status": "Pending", + } + if intent.status == "processing": + # Async method still settling; the payment_intent.succeeded webhook finalizes it. + return {"redirect_to": _success_redirect(dict(intent.get("metadata") or {})), "status": "Pending"} + return {"redirect_to": "payment-failed", "status": "Failed"} + + def finalize_payment_intent(self, intent, integration_request=None): + """Mark the original Integration Request complete and run on_payment_authorized. + + Idempotent: callable from the synchronous return AND the webhook. Keyed on + the Integration Request stamped in the intent's metadata, so it runs once. + """ + metadata = dict(intent.get("metadata") or {}) + ir_name = integration_request or 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": intent.get("id")}, "name") + self.integration_request = ( + frappe.get_doc("Integration Request", ir) + if ir + else create_request_log(intent, service_name="Stripe") + ) + + if self.integration_request.status == "Completed": + return {"redirect_to": _success_redirect(metadata), "status": "Completed"} + + 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", intent.get("id"), update_modified=False) + self.flags.status_changed_to = "Completed" + return self.finalize_request() + + def enable_setup_future_usage(self, payment_intent, client_secret, reference_doctype, reference_docname): + """Enable off-session reuse on an unconfirmed PaymentIntent (explicit consent).""" + client = get_stripe_client(self) + intent = client.payment_intents.retrieve(payment_intent) + # Ownership proof: only the browser that created the intent holds its client_secret. + if not client_secret or not hmac.compare_digest(intent.client_secret or "", client_secret): + frappe.throw(_("Invalid payment session."), frappe.PermissionError) + self.data = frappe._dict( + {"reference_doctype": reference_doctype, "reference_docname": reference_docname} + ) + self.assert_intent_matches_reference(intent) + if intent.status not in ("requires_payment_method", "requires_confirmation"): + return {"updated": False} + if not intent.get("customer"): + return {"updated": False} + client.payment_intents.modify(payment_intent, {"setup_future_usage": "off_session"}) + return {"updated": True} + 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). @@ -357,6 +474,7 @@ def create_checkout_session(self, data): + quote(self.name) ) metadata = self.get_stripe_metadata(data=data, integration_request=integration_request.name) + customer_id = self.resolve_stripe_customer(client, data) session = client.checkout.sessions.create( { @@ -376,9 +494,11 @@ def create_checkout_session(self, data): "success_url": success_url, "cancel_url": get_url("payment-failed"), "client_reference_id": data.get("reference_docname"), - "customer_email": data.get("payer_email"), + "customer": customer_id, "payment_intent_data": {"metadata": metadata}, "metadata": metadata, + # Stripe renders its own opt-in checkbox; card saved only if the buyer ticks it. + "saved_payment_method_options": {"payment_method_save": "enabled"}, } ) integration_request.db_set("output", session.id, update_modified=False) 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 07f2b3378..eca39fdd0 100644 --- a/payments/payment_gateways/doctype/stripe_settings/test_stripe_settings.py +++ b/payments/payment_gateways/doctype/stripe_settings/test_stripe_settings.py @@ -8,13 +8,20 @@ # - 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 +from unittest.mock import MagicMock, patch import frappe +import stripe from frappe.tests.utils import FrappeTestCase +from payments.payment_gateways.stripe_utils import ( + get_or_create_customer, + get_stripe_settings_for_gateway, +) from payments.templates.pages.stripe_checkout import get_reference_amount, guard_payment_reference +STRIPE_SETTINGS = "payments.payment_gateways.doctype.stripe_settings.stripe_settings" + class TestStripeSettings(FrappeTestCase): def test_guard_rejects_missing_or_unknown_reference(self): @@ -72,3 +79,90 @@ def test_claim_integration_request_settles_only_once(self): # A concurrent second caller (already Completed) must lose the claim. self.assertFalse(settings.claim_integration_request()) + + def test_enable_setup_future_usage_requires_matching_client_secret(self): + """Consent can only be set with the intent's own client_secret (ownership proof).""" + settings = frappe.new_doc("Stripe Settings") + intent = frappe._dict( + { + "client_secret": "pi_1_secret_ok", + "status": "requires_confirmation", + "customer": "cus_1", + "metadata": {"reference_doctype": "Payment Request", "reference_docname": "ORDER-A"}, + } + ) + client = MagicMock() + client.payment_intents.retrieve.return_value = intent + with patch(STRIPE_SETTINGS + ".get_stripe_client", return_value=client): + with self.assertRaises(frappe.PermissionError): + settings.enable_setup_future_usage("pi_1", "wrong", "Payment Request", "ORDER-A") + client.payment_intents.modify.assert_not_called() + result = settings.enable_setup_future_usage( + "pi_1", "pi_1_secret_ok", "Payment Request", "ORDER-A" + ) + self.assertTrue(result["updated"]) + client.payment_intents.modify.assert_called_once() + + def test_gateway_resolution_walks_account_chain(self): + """Payment Gateway Account -> Payment Gateway -> Stripe Settings resolves the controller.""" + with ( + patch.object(frappe.db, "get_value") as gv, + patch.object(frappe, "get_doc", return_value=frappe._dict({"name": "Stripe"})) as gd, + ): + gv.side_effect = [ + "Stripe-Stripe", + frappe._dict({"gateway_settings": "Stripe Settings", "gateway_controller": "Stripe"}), + ] + doc = get_stripe_settings_for_gateway("Stripe-Stripe - INR - CW") + self.assertEqual(doc.name, "Stripe") + gd.assert_called_once_with("Stripe Settings", "Stripe") + + def test_gateway_resolution_falls_back_to_sole_record(self): + """A blank gateway_controller resolves to the only Stripe Settings record.""" + with ( + patch.object(frappe.db, "get_value") as gv, + patch.object(frappe, "get_all", return_value=["Stripe"]), + patch.object(frappe, "get_doc", return_value=frappe._dict({"name": "Stripe"})), + ): + gv.side_effect = [ + "Stripe-Stripe", + frappe._dict({"gateway_settings": "Stripe Settings", "gateway_controller": None}), + ] + doc = get_stripe_settings_for_gateway("acct") + self.assertEqual(doc.name, "Stripe") + + def test_gateway_resolution_ignores_non_stripe(self): + """A non-Stripe gateway returns None so the sync is skipped, not misrouted.""" + with patch.object(frappe.db, "get_value") as gv: + gv.side_effect = [ + "Razorpay-X", + frappe._dict({"gateway_settings": "Razorpay Settings", "gateway_controller": "X"}), + ] + self.assertIsNone(get_stripe_settings_for_gateway("acct")) + + def test_get_or_create_customer_does_not_duplicate_on_transient_error(self): + """A transient Stripe error on lookup propagates instead of creating a duplicate.""" + client = MagicMock() + client.customers.retrieve.side_effect = stripe.error.AuthenticationError("bad key") + with ( + patch.object(frappe.db, "has_column", return_value=True), + patch.object(frappe.db, "get_value", return_value="cus_old"), + ): + with self.assertRaises(stripe.error.AuthenticationError): + get_or_create_customer(client, customer="ACME") + client.customers.create.assert_not_called() + + def test_get_or_create_customer_recovers_from_stale_id(self): + """A stale/deleted id (InvalidRequestError) falls through to recreate.""" + client = MagicMock() + client.customers.retrieve.side_effect = stripe.error.InvalidRequestError("no such customer", "id") + client.customers.search.return_value = frappe._dict({"data": []}) + client.customers.create.return_value = frappe._dict({"id": "cus_new"}) + with ( + patch.object(frappe.db, "has_column", return_value=True), + patch.object(frappe.db, "get_value", return_value="cus_stale"), + patch.object(frappe.db, "set_value"), + ): + cid = get_or_create_customer(client, customer="ACME") + self.assertEqual(cid, "cus_new") + client.customers.create.assert_called_once() diff --git a/payments/payment_gateways/stripe_subscription_sync.py b/payments/payment_gateways/stripe_subscription_sync.py index e9b3fcb46..cca5a61b4 100644 --- a/payments/payment_gateways/stripe_subscription_sync.py +++ b/payments/payment_gateways/stripe_subscription_sync.py @@ -3,6 +3,7 @@ import frappe from frappe import _ +from frappe.utils import cint from payments.payment_gateways.stripe_utils import ( get_stripe_settings_for_gateway, @@ -50,7 +51,7 @@ def sync_stripe_price(doc, method=None): unit_amount = to_minor_units(unit_cost, doc.currency) recurring = { "interval": INTERVAL_MAP[doc.billing_interval], - "interval_count": doc.billing_interval_count, + "interval_count": cint(doc.billing_interval_count) or 1, } try: diff --git a/payments/payment_gateways/stripe_utils.py b/payments/payment_gateways/stripe_utils.py index 34daf4416..ed74aa8be 100644 --- a/payments/payment_gateways/stripe_utils.py +++ b/payments/payment_gateways/stripe_utils.py @@ -9,6 +9,7 @@ import hashlib import frappe +import stripe from frappe.utils import flt # Pin the Stripe API version to the one bundled with stripe~=10.12 so behaviour @@ -86,6 +87,63 @@ def idempotency_key(*parts): return hashlib.sha256(raw.encode("utf-8")).hexdigest() +def get_or_create_customer(client, customer=None, email=None, name=None): + """Return a Stripe customer id, reusing Customer.stripe_customer_id when possible. + + `customer` is the ERPNext Customer name (optional). When given, the resolved + Stripe id is cached back onto Customer.stripe_customer_id so the same Stripe + customer is reused for every future charge / subscription of that party. + """ + has_field = bool(customer) and frappe.db.has_column("Customer", "stripe_customer_id") + + # Reuse the id cached on the Customer. + if has_field: + existing = frappe.db.get_value("Customer", customer, "stripe_customer_id") + if existing: + try: + obj = client.customers.retrieve(existing) + if not obj.get("deleted"): + return existing + except stripe.error.InvalidRequestError: + pass # stale / deleted id — fall through; other errors surface, not swallowed + + # Recover a Stripe customer by metadata to dedupe a prior rolled-back attempt. + if customer: + found = _find_stripe_customer_by_party(client, customer) + if found: + if has_field: + frappe.db.set_value("Customer", customer, "stripe_customer_id", found, update_modified=False) + return found + + # Create; name/email can differ per call so no idempotency key (metadata dedupes). + obj = client.customers.create( + { + "email": email, + "name": name or customer, + "metadata": {"erpnext_customer": customer} if customer else {}, + } + ) + if has_field: + frappe.db.set_value("Customer", customer, "stripe_customer_id", obj.id, update_modified=False) + return obj.id + + +def _find_stripe_customer_by_party(client, customer): + """Find a non-deleted Stripe customer previously created for this ERPNext party.""" + # Escape quotes so an apostrophe in the party name can't break the search query. + safe_customer = customer.replace("\\", "\\\\").replace("'", "\\'") + try: + result = client.customers.search( + {"query": f"metadata['erpnext_customer']:'{safe_customer}'", "limit": 1} + ) + except Exception: + return None # search index unavailable / eventual-consistency miss + for obj in result.get("data") or []: + if not obj.get("deleted"): + return obj.id + return None + + def get_stripe_settings_for_gateway(payment_gateway_account): """Resolve a Payment Gateway Account name to its Stripe Settings doc. @@ -100,6 +158,7 @@ def get_stripe_settings_for_gateway(payment_gateway_account): 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 + # No explicit controller: use the sole Stripe Settings record if unambiguous. + names = frappe.get_all("Stripe Settings", pluck="name", limit=2) + return frappe.get_doc("Stripe Settings", names[0]) if len(names) == 1 else None return frappe.get_doc("Stripe Settings", gw.gateway_controller) diff --git a/payments/templates/includes/stripe_checkout.js b/payments/templates/includes/stripe_checkout.js index ec986710d..a788b628b 100644 --- a/payments/templates/includes/stripe_checkout.js +++ b/payments/templates/includes/stripe_checkout.js @@ -15,6 +15,7 @@ var checkoutData = { var elements; var paymentElement; var clientSecret; +var paymentIntentId; function showError(message) { var displayError = document.getElementById('card-errors'); @@ -56,6 +57,7 @@ function mountPaymentElement() { return; } clientSecret = r.message.client_secret; + paymentIntentId = r.message.payment_intent; elements = stripe.elements({ clientSecret: clientSecret }); paymentElement = elements.create('payment', { defaultValues: { @@ -76,6 +78,31 @@ function confirmPayment() { return; } setSubmitting(true); + recordConsentThen(doConfirm); +} + +function recordConsentThen(next) { + // Record save-card consent before confirming, so cards store only on opt-in. + if (!$('#save-card').is(':checked') || !paymentIntentId) { + next(); + return; + } + frappe.call({ + method: "payments.templates.pages.stripe_checkout.set_card_consent", + headers: { "X-Requested-With": "XMLHttpRequest" }, + args: { + payment_intent: paymentIntentId, + client_secret: clientSecret, + reference_doctype: checkoutData.reference_doctype, + reference_docname: checkoutData.reference_docname, + payment_gateway: checkoutData.payment_gateway + }, + // Don't block payment if the consent write fails; the card just won't be saved. + always: function () { next(); } + }); +} + +function doConfirm() { stripe.confirmPayment({ elements: elements, redirect: 'if_required', @@ -106,12 +133,19 @@ function confirmPayment() { callback: function (r) { var msg = r.message || {}; $('#submit').hide(); - if (msg.status === "Completed") { + if (msg.status === "Completed" || msg.status === "Pending") { + // Pending = async method (ACH/SEPA) accepted and still settling, not a failure. $('.success').show(); } else { $('.error').show(); } redirectAfter(msg); + }, + error: function () { + // Payment already captured; keep the button disabled to avoid a double-charge. + $('#submit').hide(); + showError(__('Your payment was received and is being processed. Please do not pay again — if anything looks wrong, contact us.')); + $('.error').hide(); } }); } else { diff --git a/payments/templates/pages/stripe_checkout.html b/payments/templates/pages/stripe_checkout.html index b8b56065f..f1c32d56a 100644 --- a/payments/templates/pages/stripe_checkout.html +++ b/payments/templates/pages/stripe_checkout.html @@ -46,6 +46,12 @@

+ +
+
diff --git a/payments/templates/pages/stripe_checkout.py b/payments/templates/pages/stripe_checkout.py index 85ec60efc..0a63853df 100644 --- a/payments/templates/pages/stripe_checkout.py +++ b/payments/templates/pages/stripe_checkout.py @@ -48,16 +48,22 @@ def get_context(context): limit=1, ) if payment_plans: - billing_interval, billing_interval_count = frappe.db.get_value( - "Subscription Plan", payment_plans[0].plan, ["billing_interval", "billing_interval_count"] + plan = frappe.db.get_value( + "Subscription Plan", + payment_plans[0].plan, + ["billing_interval", "billing_interval_count"], + as_dict=True, ) - 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 + if plan: + billing_interval_count = cint(plan.billing_interval_count) or 1 + if billing_interval_count == 1: + recurrence = _("per {0}").format(_(plan.billing_interval)) + else: + recurrence = _("every {0} {1}s").format( + billing_interval_count, _(plan.billing_interval) + ) + + context["amount"] = context["amount"] + " " + recurrence else: frappe.redirect_to_message( _("Some information is missing"), @@ -108,6 +114,9 @@ def get_reference_amount(reference_doctype, reference_docname): 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) + if not row: + # Reference vanished between the guard's exists check and here (TOCTOU). + frappe.throw(_("Payment reference {0} no longer exists.").format(reference_docname)) return row.get(amount_field), row.get("currency") @@ -120,9 +129,7 @@ def create_payment_intent(data, reference_doctype=None, reference_docname=None, """ 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. + # Amount/reference are authoritative server-side and bind the intent to this order. data["reference_doctype"] = reference_doctype data["reference_docname"] = reference_docname data["amount"], currency = get_reference_amount(reference_doctype, reference_docname) @@ -135,6 +142,39 @@ def create_payment_intent(data, reference_doctype=None, reference_docname=None, return result +@frappe.whitelist() +def save_card(data, reference_doctype=None, reference_docname=None, payment_gateway=None): + """Create a SetupIntent so a card can be saved off-session for later reuse. + + Unlike the one-off checkout (which guests legitimately complete), storing a + card is a standing capability to charge later, so this requires an authenticated + user. Omitting allow_guest makes the framework reject Guest callers; the guard + below then enforces read access on the reference for that logged-in user. + """ + guard_payment_reference(reference_doctype, reference_docname) + data = json.loads(data) + gateway_controller = get_gateway_controller(reference_doctype, reference_docname, payment_gateway) + settings = frappe.get_doc("Stripe Settings", gateway_controller) + result = settings.create_setup_intent_for_card(frappe._dict(data)) + frappe.db.commit() + return result + + +@frappe.whitelist(allow_guest=True) +def set_card_consent( + payment_intent, client_secret=None, reference_doctype=None, reference_docname=None, payment_gateway=None +): + """Record "save my card" consent on the checkout PaymentIntent (embedded flow).""" + guard_payment_reference(reference_doctype, reference_docname) + gateway_controller = get_gateway_controller(reference_doctype, reference_docname, payment_gateway) + settings = frappe.get_doc("Stripe Settings", gateway_controller) + result = settings.enable_setup_future_usage( + payment_intent, client_secret, reference_doctype, reference_docname + ) + frappe.db.commit() + return result + + @frappe.whitelist(allow_guest=True) def make_payment( data, @@ -147,8 +187,6 @@ def make_payment( 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) From 52d8806177828f23bfbc6ac40d417bd870cc667c Mon Sep 17 00:00:00 2001 From: Tarunkumar0601 Date: Mon, 29 Jun 2026 13:37:26 +0530 Subject: [PATCH 5/8] feat(stripe): webhook endpoint and one-off reconciliation --- .../doctype/stripe_settings/__init__.py | 127 ++++++++++++++++++ .../stripe_settings/stripe_settings.py | 24 +++- .../doctype/stripe_webhook_log/__init__.py | 0 .../stripe_webhook_log.json | 116 ++++++++++++++++ .../stripe_webhook_log/stripe_webhook_log.py | 28 ++++ payments/payment_gateways/stripe_reconcile.py | 67 +++++++++ payments/payment_gateways/stripe_utils.py | 4 +- 7 files changed, 363 insertions(+), 3 deletions(-) create mode 100644 payments/payment_gateways/doctype/stripe_webhook_log/__init__.py create mode 100644 payments/payment_gateways/doctype/stripe_webhook_log/stripe_webhook_log.json create mode 100644 payments/payment_gateways/doctype/stripe_webhook_log/stripe_webhook_log.py create mode 100644 payments/payment_gateways/stripe_reconcile.py diff --git a/payments/payment_gateways/doctype/stripe_settings/__init__.py b/payments/payment_gateways/doctype/stripe_settings/__init__.py index e69de29bb..3d32635ad 100644 --- a/payments/payment_gateways/doctype/stripe_settings/__init__.py +++ b/payments/payment_gateways/doctype/stripe_settings/__init__.py @@ -0,0 +1,127 @@ +# Copyright (c) Frappe Technologies Pvt. Ltd. and contributors +# License: MIT. See LICENSE +# +# Stripe webhook endpoint; configure Stripe to POST here and set the signing secret. + +import frappe + +WEBHOOK_SECRET_CACHE_KEY = "stripe_webhook_secrets" + + +@frappe.whitelist(allow_guest=True) +def webhooks(): + r = frappe.request + if not r: + return + + payload = r.get_data() + sig_header = frappe.get_request_header("Stripe-Signature") + + event, settings = construct_event(payload, sig_header) + if event is None: + # Bad/absent signature — tell Stripe to stop, do not process. + frappe.local.response["http_status_code"] = 400 + return {"status": "invalid signature"} + + return handle_event(event, settings) + + +def construct_event(payload, sig_header): + """Verify the signature against every configured Stripe account's secret. + + Returns (event, settings_doc) on success, (None, None) on failure. + """ + import stripe + + if not sig_header: + return None, None + + for settings_name, secret in get_webhook_secrets(): + try: + event = stripe.Webhook.construct_event(payload, sig_header, secret) + return event, frappe.get_doc("Stripe Settings", settings_name) + except stripe.error.SignatureVerificationError: + continue + except frappe.DoesNotExistError: + # Settings deleted since the cache was populated; refresh and skip it. + clear_cache() + continue + except ValueError: + # Malformed payload — no point trying other secrets. + return None, None + + return None, None + + +def handle_event(event, settings): + """Dedupe on the Stripe event id, then route to the reconciler. + + A prior *Failed* attempt is allowed to reprocess (its log row is reused); any + other prior status is a genuine duplicate and is skipped. + """ + event_id = event["id"] + prior = frappe.db.get_value( + "Stripe Webhook Log", {"stripe_event_id": event_id}, ["name", "status"], as_dict=True + ) + if prior and prior.status != "Failed": + return {"status": "duplicate"} + + if prior: + log = frappe.get_doc("Stripe Webhook Log", prior.name) + else: + obj = event["data"]["object"] + log = frappe.get_doc( + { + "doctype": "Stripe Webhook Log", + "stripe_event_id": event_id, + "event_type": event["type"], + "stripe_object_id": obj.get("id"), + "status": "Received", + "stripe_settings": settings.name if settings else None, + "payload": frappe.as_json(event), + } + ) + try: + log.insert(ignore_permissions=True) + frappe.db.commit() # persist the dedupe row before doing any work + except frappe.exceptions.DuplicateEntryError: + # Concurrent delivery already inserted it. + return {"status": "duplicate"} + + try: + from payments.payment_gateways import stripe_reconcile + + result = stripe_reconcile.route_event(event, settings) or {} + log.db_set("status", result.get("status_label", "Processed"), update_modified=False) + if result.get("reference_doctype"): + log.db_set("reference_doctype", result.get("reference_doctype"), update_modified=False) + log.db_set("reference_name", result.get("reference_name"), update_modified=False) + frappe.db.commit() + except Exception: + frappe.db.rollback() + log.db_set("status", "Failed", update_modified=False) + log.db_set("error", frappe.get_traceback(), update_modified=False) + frappe.db.commit() + frappe.log_error(frappe.get_traceback(), "Stripe webhook processing failed") + # Transient failure — ask Stripe to retry; the dedupe above reprocesses the Failed row. + frappe.local.response["http_status_code"] = 500 + return {"status": "error"} + + return {"status": "ok"} + + +def get_webhook_secrets(): + def _load(): + secrets = [] + for name in frappe.get_all("Stripe Settings", pluck="name"): + doc = frappe.get_doc("Stripe Settings", name) + secret = doc.get_password("webhook_secret", raise_exception=False) + if secret: + secrets.append((name, secret)) + return secrets + + return frappe.cache().get_value(WEBHOOK_SECRET_CACHE_KEY, _load) or [] + + +def clear_cache(): + frappe.cache().delete_value(WEBHOOK_SECRET_CACHE_KEY) diff --git a/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py b/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py index 073aed22d..57cb7bb1c 100644 --- a/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py +++ b/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py @@ -168,6 +168,17 @@ def on_update(self): call_hook_method("payment_gateway_enabled", gateway="Stripe-" + self.gateway_name) if not self.flags.ignore_mandatory: self.validate_stripe_credentails() + self.set_webhook_endpoint() + clear_webhook_secret_cache() + + def on_trash(self): + clear_webhook_secret_cache() + + def set_webhook_endpoint(self): + """Show the admin which URL to register as a Stripe webhook endpoint.""" + endpoint = get_url("/api/method/payments.payment_gateways.doctype.stripe_settings.webhooks") + if self.webhook_endpoint != endpoint: + self.db_set("webhook_endpoint", endpoint, update_modified=False) def validate_stripe_credentails(self): if self.publishable_key and self.secret_key: @@ -297,7 +308,11 @@ def create_payment_intent_for_checkout(self, data): 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).""" + """Confirm a one-off payment via PaymentIntents (SCA/3DS ready). + + Server-side create+confirm path (a direct payment_method or a legacy + card token). The embedded flow goes through finalize_payment_intent_by_id. + """ client = self.stripe try: payment_method = self.data.get("payment_method") @@ -648,6 +663,13 @@ def get_gateway_controller(doctype, docname, payment_gateway=None): return frappe.db.get_value("Payment Gateway", payment_gateway, "gateway_controller") +def clear_webhook_secret_cache(): + # Single source of the cache key lives in this package's __init__ (the reader). + from payments.payment_gateways.doctype.stripe_settings import clear_cache + + clear_cache() + + def _success_redirect(metadata): """payment-success URL carrying the reference, so the success page can load it.""" dt = (metadata or {}).get("reference_doctype") diff --git a/payments/payment_gateways/doctype/stripe_webhook_log/__init__.py b/payments/payment_gateways/doctype/stripe_webhook_log/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/payments/payment_gateways/doctype/stripe_webhook_log/stripe_webhook_log.json b/payments/payment_gateways/doctype/stripe_webhook_log/stripe_webhook_log.json new file mode 100644 index 000000000..191569c59 --- /dev/null +++ b/payments/payment_gateways/doctype/stripe_webhook_log/stripe_webhook_log.json @@ -0,0 +1,116 @@ +{ + "actions": [], + "allow_rename": 0, + "creation": "2026-06-26 00:00:00.000000", + "default_view": "List", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "stripe_event_id", + "event_type", + "stripe_object_id", + "status", + "column_break_main", + "stripe_settings", + "reference_doctype", + "reference_name", + "section_break_payload", + "payload", + "error" + ], + "fields": [ + { + "fieldname": "stripe_event_id", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Stripe Event ID", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "event_type", + "fieldtype": "Data", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Event Type" + }, + { + "fieldname": "stripe_object_id", + "fieldtype": "Data", + "label": "Stripe Object ID" + }, + { + "default": "Received", + "fieldname": "status", + "fieldtype": "Select", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Status", + "options": "Received\nProcessed\nIgnored\nFailed" + }, + { + "fieldname": "column_break_main", + "fieldtype": "Column Break" + }, + { + "fieldname": "stripe_settings", + "fieldtype": "Link", + "label": "Stripe Settings", + "options": "Stripe Settings" + }, + { + "fieldname": "reference_doctype", + "fieldtype": "Data", + "label": "Reference Document Type" + }, + { + "fieldname": "reference_name", + "fieldtype": "Data", + "label": "Reference Name" + }, + { + "fieldname": "section_break_payload", + "fieldtype": "Section Break", + "label": "Payload" + }, + { + "fieldname": "payload", + "fieldtype": "Code", + "label": "Payload", + "options": "JSON" + }, + { + "fieldname": "error", + "fieldtype": "Long Text", + "label": "Error" + } + ], + "index_web_pages_for_search": 1, + "links": [], + "modified": "2026-06-26 00:00:00.000000", + "modified_by": "Administrator", + "module": "Payment Gateways", + "name": "Stripe Webhook Log", + "naming_rule": "Expression (old style)", + "autoname": "format:STRWHL-{#####}", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/payments/payment_gateways/doctype/stripe_webhook_log/stripe_webhook_log.py b/payments/payment_gateways/doctype/stripe_webhook_log/stripe_webhook_log.py new file mode 100644 index 000000000..9a79c4714 --- /dev/null +++ b/payments/payment_gateways/doctype/stripe_webhook_log/stripe_webhook_log.py @@ -0,0 +1,28 @@ +# Copyright (c) Frappe Technologies Pvt. Ltd. and contributors +# License: MIT. See LICENSE + +import frappe +from frappe.model.document import Document + + +class StripeWebhookLog(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + error: DF.LongText | None + event_type: DF.Data | None + payload: DF.Code | None + reference_doctype: DF.Data | None + reference_name: DF.Data | None + status: DF.Literal["Received", "Processed", "Ignored", "Failed"] + stripe_event_id: DF.Data + stripe_object_id: DF.Data | None + stripe_settings: DF.Link | None + # end: auto-generated types + + pass diff --git a/payments/payment_gateways/stripe_reconcile.py b/payments/payment_gateways/stripe_reconcile.py new file mode 100644 index 000000000..645385b22 --- /dev/null +++ b/payments/payment_gateways/stripe_reconcile.py @@ -0,0 +1,67 @@ +# Copyright (c) Frappe Technologies Pvt. Ltd. and contributors +# License: MIT. See LICENSE +# +# Maps verified Stripe webhook events onto ERPNext records; handlers are idempotent. + +import frappe + +from payments.payment_gateways.stripe_utils import get_stripe_client + + +def route_event(event, settings): + handler = _HANDLERS.get(event["type"]) + if not handler: + return {"status_label": "Ignored"} + return handler(event, settings) + + +def reconcile_checkout_session(event, settings): + """checkout.session.completed — Hosted Checkout one-off / first payment.""" + session = event["data"]["object"] + result = settings.finalize_checkout_session(session["id"]) or {} + meta = dict(session.get("metadata") or {}) + return { + "status_label": "Failed" if result.get("status") == "Failed" else "Processed", + "reference_doctype": meta.get("reference_doctype"), + "reference_name": meta.get("reference_docname"), + } + + +def reconcile_one_off(event, settings): + """payment_intent.succeeded — backstop for one-off payments. + + PaymentIntents that belong to a Stripe invoice (subscriptions) are handled + via invoice.paid, so they are skipped here. + """ + intent = event["data"]["object"] + if intent.get("invoice"): + return {"status_label": "Ignored"} + + meta = dict(intent.get("metadata") or {}) + if not meta.get("reference_docname"): + return {"status_label": "Ignored"} + + result = settings.finalize_payment_intent(intent) or {} + return { + "status_label": "Failed" if result.get("status") == "Failed" else "Processed", + "reference_doctype": meta.get("reference_doctype"), + "reference_name": meta.get("reference_docname"), + } + + +def handle_setup_intent_succeeded(event, settings): + """setup_intent.succeeded — make the saved card the customer's default.""" + si = event["data"]["object"] + customer = si.get("customer") + payment_method = si.get("payment_method") + if customer and payment_method: + client = get_stripe_client(settings) + client.customers.update(customer, {"invoice_settings": {"default_payment_method": payment_method}}) + return {"status_label": "Processed"} + + +_HANDLERS = { + "checkout.session.completed": reconcile_checkout_session, + "payment_intent.succeeded": reconcile_one_off, + "setup_intent.succeeded": handle_setup_intent_succeeded, +} diff --git a/payments/payment_gateways/stripe_utils.py b/payments/payment_gateways/stripe_utils.py index ed74aa8be..7a0318aee 100644 --- a/payments/payment_gateways/stripe_utils.py +++ b/payments/payment_gateways/stripe_utils.py @@ -136,8 +136,8 @@ def _find_stripe_customer_by_party(client, customer): result = client.customers.search( {"query": f"metadata['erpnext_customer']:'{safe_customer}'", "limit": 1} ) - except Exception: - return None # search index unavailable / eventual-consistency miss + except stripe.error.StripeError: + return None # search unavailable / eventual-consistency miss; other errors surface for obj in result.get("data") or []: if not obj.get("deleted"): return obj.id From 848df018895ef8c00e9cc6918d689f55e8be910c Mon Sep 17 00:00:00 2001 From: Tarunkumar0601 Date: Mon, 29 Jun 2026 13:42:34 +0530 Subject: [PATCH 6/8] feat(stripe): subscriptions and recurring reconciliation --- .../stripe_settings/stripe_settings.py | 113 +++++++--- .../payment_gateways/stripe_integration.py | 100 +++++++-- payments/payment_gateways/stripe_reconcile.py | 204 +++++++++++++++++- .../stripe_subscription_sync.py | 7 +- payments/payment_gateways/stripe_utils.py | 67 ++++++ 5 files changed, 440 insertions(+), 51 deletions(-) diff --git a/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py b/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py index 57cb7bb1c..541f7a6cd 100644 --- a/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py +++ b/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py @@ -211,11 +211,22 @@ def validate_minimum_transaction_amount(self, currency, amount): ) def get_payment_url(self, **kwargs): + # Subscriptions use Hosted Checkout so Stripe sets up recurring billing natively. + if self.is_subscription_reference(kwargs): + return self.create_checkout_session(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 is_subscription_reference(self, data): + dt, dn = data.get("reference_doctype"), data.get("reference_docname") + if not dt or not dn or not frappe.db.exists(dt, dn): + return False + if not frappe.get_meta(dt).has_field("is_a_subscription"): + return False + return bool(frappe.db.get_value(dt, dn, "is_a_subscription")) + def get_stripe_metadata(self, data=None, integration_request=None): """Stripe metadata is string->string; drop empty values.""" data = data or self.data @@ -491,31 +502,34 @@ def create_checkout_session(self, data): metadata = self.get_stripe_metadata(data=data, integration_request=integration_request.name) customer_id = self.resolve_stripe_customer(client, data) - 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") + if self.is_subscription_reference(data): + session = self._create_subscription_checkout(client, data, customer_id, metadata, success_url) + else: + 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": customer_id, - "payment_intent_data": {"metadata": metadata}, - "metadata": metadata, - # Stripe renders its own opt-in checkbox; card saved only if the buyer ticks it. - "saved_payment_method_options": {"payment_method_save": "enabled"}, - } - ) + "quantity": 1, + } + ], + "success_url": success_url, + "cancel_url": get_url("payment-failed"), + "client_reference_id": data.get("reference_docname"), + "customer": customer_id, + "payment_intent_data": {"metadata": metadata}, + "metadata": metadata, + # Stripe renders its own opt-in checkbox; card saved only if the buyer ticks it. + "saved_payment_method_options": {"payment_method_save": "enabled"}, + } + ) integration_request.db_set("output", session.id, update_modified=False) return session.url @@ -536,6 +550,57 @@ def claim_integration_request(self): self.integration_request.db_set("status", "Completed", update_modified=False) return True + def _create_subscription_checkout(self, client, data, customer_id, metadata, success_url): + """Hosted Checkout in subscription mode (both billing models).""" + from payments.payment_gateways.stripe_utils import ( + find_erpnext_subscription, + get_subscription_line_items, + ) + + dt, dn = data.get("reference_doctype"), data.get("reference_docname") + line_items = get_subscription_line_items(dt, dn) + + party = self.get_party_for_reference(data) + plan_names = [ + row.plan + for row in frappe.get_all( + "Subscription Plan Detail", + filters={"parent": dn, "parenttype": dt}, + fields=["plan"], + ) + ] + sub_metadata = dict(metadata) + erpnext_sub = find_erpnext_subscription(party, plan_names) + if erpnext_sub: + sub_metadata["erpnext_subscription"] = erpnext_sub + if party: + sub_metadata["erpnext_customer"] = party + + subscription_data = {"metadata": sub_metadata} + + if (self.subscription_billing_model or "") == "Charge Now + Defer First Cycle": + # A one-off charge can't ride in subscription-mode line_items; this model needs a + # separate first-invoice charge (not yet implemented). Fail clearly for now. + frappe.throw( + _( + "The 'Charge Now + Defer First Cycle' billing model is not supported yet — " + "please use 'Bill From Cycle One'." + ) + ) + + return client.checkout.sessions.create( + { + "mode": "subscription", + "line_items": line_items, + "success_url": success_url, + "cancel_url": get_url("payment-failed"), + "client_reference_id": dn, + "customer": customer_id, + "metadata": sub_metadata, + "subscription_data": subscription_data, + } + ) + def finalize_checkout_session(self, session_id): """Confirm a Hosted Checkout session and run on_payment_authorized. diff --git a/payments/payment_gateways/stripe_integration.py b/payments/payment_gateways/stripe_integration.py index 2d7e8a5d3..41517e0f6 100644 --- a/payments/payment_gateways/stripe_integration.py +++ b/payments/payment_gateways/stripe_integration.py @@ -1,28 +1,33 @@ # Copyright (c) 2018, Frappe Technologies Pvt. Ltd. and contributors -# For license information, please see license.txt +# License: MIT. See LICENSE +# +# Programmatic subscription creation (V1 create_subscription); Hosted Checkout is primary. import frappe -import stripe from frappe import _ from frappe.integrations.utils import create_request_log +from payments.payment_gateways.stripe_utils import ( + find_erpnext_subscription, + get_or_create_customer, + get_stripe_client, + get_subscription_line_items, + idempotency_key, + link_stripe_subscription, +) + def create_stripe_subscription(gateway_controller, data): stripe_settings = frappe.get_doc("Stripe Settings", gateway_controller) stripe_settings.data = frappe._dict(data) - - stripe.api_key = stripe_settings.get_password(fieldname="secret_key", raise_exception=False) - stripe.default_http_client = stripe.http_client.RequestsClient() + stripe_settings.stripe = get_stripe_client(stripe_settings) try: stripe_settings.integration_request = create_request_log(stripe_settings.data, "Host", "Stripe") - stripe_settings.payment_plans = frappe.get_doc( - "Payment Request", stripe_settings.data.reference_docname - ).subscription_plans return create_subscription_on_stripe(stripe_settings) except Exception: - stripe_settings.log_error("Unable to create Stripe subscription") + frappe.log_error(frappe.get_traceback(), "Unable to create Stripe subscription") return { "redirect_to": frappe.redirect_to_message( _("Server Error"), @@ -35,29 +40,78 @@ def create_stripe_subscription(gateway_controller, data): def create_subscription_on_stripe(stripe_settings): - items = [] - for payment_plan in stripe_settings.payment_plans: - plan = frappe.db.get_value("Subscription Plan", payment_plan.plan, "product_price_id") - items.append({"price": plan, "quantity": payment_plan.qty}) + client = stripe_settings.stripe + data = stripe_settings.data - try: - customer = stripe.Customer.create( - source=stripe_settings.data.stripe_token_id, - description=stripe_settings.data.payer_name, - email=stripe_settings.data.payer_email, + pr = frappe.get_doc("Payment Request", data.reference_docname) + party = pr.party if pr.party_type == "Customer" else None + items = get_subscription_line_items("Payment Request", pr.name) + plan_names = [ + row.plan + for row in frappe.get_all( + "Subscription Plan Detail", + filters={"parent": pr.name, "parenttype": "Payment Request"}, + fields=["plan"], + ) + ] + + customer_id = get_or_create_customer( + client, customer=party, email=data.get("payer_email"), name=data.get("payer_name") or party + ) + erpnext_sub = find_erpnext_subscription(party, plan_names) + metadata = {"reference_doctype": "Payment Request", "reference_docname": pr.name} + if erpnext_sub: + metadata["erpnext_subscription"] = erpnext_sub + if party: + metadata["erpnext_customer"] = party + + if (stripe_settings.subscription_billing_model or "") == "Charge Now + Defer First Cycle": + frappe.throw( + _( + "The 'Charge Now + Defer First Cycle' billing model is not supported yet — " + "please use 'Bill From Cycle One'." + ) ) - subscription = stripe.Subscription.create(customer=customer, items=items) + try: + create_args = { + "customer": customer_id, + "items": items, + "metadata": metadata, + "payment_behavior": "default_incomplete", + "payment_settings": {"save_default_payment_method": "on_subscription"}, + "expand": ["latest_invoice.payment_intent"], + } + + subscription = client.subscriptions.create( + create_args, {"idempotency_key": idempotency_key("sub", pr.name)} + ) + stripe_settings.integration_request.db_set("output", subscription.id, update_modified=False) + link_stripe_subscription(erpnext_sub, subscription.id, customer_id) - if subscription.status == "active": + if subscription.status in ("active", "trialing"): stripe_settings.integration_request.db_set("status", "Completed", update_modified=False) stripe_settings.flags.status_changed_to = "Completed" - + elif subscription.status == "incomplete": + # First invoice needs client-side confirmation (3DS / no default card). + # Don't mark the Payment Request paid; the invoice.paid webhook will. + intent = getattr(getattr(subscription, "latest_invoice", None), "payment_intent", None) + if intent is not None: + return { + "requires_action": True, + "client_secret": intent.client_secret, + "payment_intent": intent.id, + "status": "Pending", + } + stripe_settings.integration_request.db_set("status", "Pending", update_modified=False) else: stripe_settings.integration_request.db_set("status", "Failed", update_modified=False) - frappe.log_error(f"Stripe Subscription ID {subscription.id}: Payment failed") + frappe.log_error(f"Stripe Subscription ID {subscription.id}: status {subscription.status}") + except Exception: stripe_settings.integration_request.db_set("status", "Failed", update_modified=False) - stripe_settings.log_error("Unable to create Stripe subscription") + frappe.log_error(frappe.get_traceback(), "Unable to create Stripe subscription") + stripe_settings.data.setdefault("reference_doctype", "Payment Request") + stripe_settings.data.setdefault("reference_docname", pr.name) return stripe_settings.finalize_request() diff --git a/payments/payment_gateways/stripe_reconcile.py b/payments/payment_gateways/stripe_reconcile.py index 645385b22..9c900d6a6 100644 --- a/payments/payment_gateways/stripe_reconcile.py +++ b/payments/payment_gateways/stripe_reconcile.py @@ -4,8 +4,13 @@ # Maps verified Stripe webhook events onto ERPNext records; handlers are idempotent. import frappe +import stripe +from frappe.utils import flt, getdate, now_datetime -from payments.payment_gateways.stripe_utils import get_stripe_client +from payments.payment_gateways.stripe_utils import ( + get_stripe_client, + link_stripe_subscription, +) def route_event(event, settings): @@ -16,10 +21,18 @@ def route_event(event, settings): def reconcile_checkout_session(event, settings): - """checkout.session.completed — Hosted Checkout one-off / first payment.""" + """checkout.session.completed — Hosted Checkout one-off / subscription start.""" session = event["data"]["object"] - result = settings.finalize_checkout_session(session["id"]) or {} meta = dict(session.get("metadata") or {}) + + if session.get("mode") == "subscription" and session.get("subscription"): + # Link the ERPNext Subscription so invoice.paid can reconcile each cycle. + erpnext_sub = meta.get("erpnext_subscription") + if erpnext_sub and frappe.db.exists("Subscription", erpnext_sub): + link_stripe_subscription(erpnext_sub, session.get("subscription"), session.get("customer")) + + # Mark the originating Payment Request paid (idempotent). + result = settings.finalize_checkout_session(session["id"]) or {} return { "status_label": "Failed" if result.get("status") == "Failed" else "Processed", "reference_doctype": meta.get("reference_doctype"), @@ -27,6 +40,187 @@ def reconcile_checkout_session(event, settings): } +def reconcile_recurring_invoice(event, settings): + """invoice.paid — map a paid Stripe subscription invoice to its ERPNext SI.""" + invoice = event["data"]["object"] + if not invoice.get("subscription"): + return {"status_label": "Ignored"} # not a subscription invoice + + # De-dupe at the ledger level too: one Payment Entry per Stripe invoice. + if frappe.db.exists("Payment Entry", {"reference_no": invoice.get("id"), "docstatus": 1}): + return {"status_label": "Ignored"} + + erpnext_sub = _find_linked_subscription(invoice, settings) + if not erpnext_sub: + return {"status_label": "Ignored"} + + si = _find_period_sales_invoice(erpnext_sub, invoice) + if not si: + si = _generate_and_find_sales_invoice(erpnext_sub, invoice) + if not si: + return {"status_label": "Ignored"} + + # The first-cycle invoice is often already settled by the checkout return (a Payment + # Entry keyed on the PaymentIntent, not the Stripe invoice id), so book against this + # SI only if it still has an outstanding balance — otherwise we'd double-book. + if flt(frappe.db.get_value("Sales Invoice", si, "outstanding_amount")) <= 0: + return {"status_label": "Ignored"} + + pe_name = _create_payment_entry(si, invoice, settings) + return { + "status_label": "Processed" if pe_name else "Ignored", + "reference_doctype": "Sales Invoice", + "reference_name": si, + } + + +def mark_dunning(event, settings): + """invoice.payment_failed — flag the ERPNext Subscription; ERPNext grace logic runs.""" + invoice = event["data"]["object"] + erpnext_sub = _find_linked_subscription(invoice, settings) + if erpnext_sub: + frappe.get_doc("Subscription", erpnext_sub).add_comment( + "Comment", _comment("Stripe reported a failed payment for invoice {0}.").format(invoice.get("id")) + ) + return { + "status_label": "Processed" if erpnext_sub else "Ignored", + "reference_doctype": "Subscription" if erpnext_sub else None, + "reference_name": erpnext_sub, + } + + +def sync_subscription_status(event, settings): + """customer.subscription.updated/deleted — keep the ERPNext link/status note in step.""" + sub = event["data"]["object"] + erpnext_sub = _subscription_from_stripe_id(sub.get("id")) or _subscription_from_metadata(sub) + if not erpnext_sub: + return {"status_label": "Ignored"} + + if event["type"] == "customer.subscription.deleted": + frappe.get_doc("Subscription", erpnext_sub).add_comment( + "Comment", _comment("Stripe subscription {0} was cancelled.").format(sub.get("id")) + ) + else: + link_stripe_subscription(erpnext_sub, sub.get("id"), sub.get("customer")) + return {"status_label": "Processed", "reference_doctype": "Subscription", "reference_name": erpnext_sub} + + +def _comment(msg): + return frappe._(msg) + + +def _find_linked_subscription(invoice, settings): + erpnext_sub = _subscription_from_stripe_id(invoice.get("subscription")) + if erpnext_sub: + return erpnext_sub + # Fall back to the Stripe subscription's metadata, then cache the link. + try: + client = get_stripe_client(settings) + sub = client.subscriptions.retrieve(invoice.get("subscription")) + except stripe.error.InvalidRequestError: + return None # genuinely not found; transient errors propagate -> logged Failed -> retried + erpnext_sub = _subscription_from_metadata(sub) + if erpnext_sub: + link_stripe_subscription(erpnext_sub, sub.get("id"), sub.get("customer")) + return erpnext_sub + + +def _subscription_from_stripe_id(stripe_subscription_id): + if not stripe_subscription_id or not frappe.db.has_column("Subscription", "stripe_subscription_id"): + return None + return frappe.db.get_value("Subscription", {"stripe_subscription_id": stripe_subscription_id}, "name") + + +def _subscription_from_metadata(stripe_sub): + name = dict(stripe_sub.get("metadata") or {}).get("erpnext_subscription") + if name and frappe.db.exists("Subscription", name): + return name + return None + + +def _invoice_period(invoice): + """(from_date, to_date) of the recurring line on a Stripe invoice.""" + lines = (invoice.get("lines") or {}).get("data") or [] + period = lines[0].get("period") if lines else None + if not period: + return None, None + from datetime import datetime, timezone + + start = getdate(datetime.fromtimestamp(period["start"], tz=timezone.utc)) + end = getdate(datetime.fromtimestamp(period["end"], tz=timezone.utc)) + return start, end + + +def _find_period_sales_invoice(erpnext_sub, invoice): + start, end = _invoice_period(invoice) + filters = {"subscription": erpnext_sub, "docstatus": 1, "is_return": 0} + if start and end: + exact = frappe.get_all( + "Sales Invoice", + filters={**filters, "from_date": start, "to_date": end}, + pluck="name", + limit=1, + ) + if exact: + return exact[0] + # Tolerance for day-level date drift: an unpaid SI whose period OVERLAPS this + # Stripe invoice's period — never a non-overlapping later cycle. Oldest first, + # since Stripe bills periods in order. + overlap = frappe.get_all( + "Sales Invoice", + filters={ + **filters, + "outstanding_amount": (">", 0), + "from_date": ("<=", end), + "to_date": (">=", start), + }, + pluck="name", + order_by="posting_date asc", + limit=1, + ) + return overlap[0] if overlap else None + # No period on the invoice: fall back to the OLDEST unpaid SI (Stripe bills in order). + unpaid = frappe.get_all( + "Sales Invoice", + filters={**filters, "outstanding_amount": (">", 0)}, + pluck="name", + order_by="posting_date asc", + limit=1, + ) + return unpaid[0] if unpaid else None + + +def _generate_and_find_sales_invoice(erpnext_sub, invoice): + """Stripe billed before ERPNext's scheduler — generate the period SI, then re-find.""" + _, end = _invoice_period(invoice) + try: + sub_doc = frappe.get_doc("Subscription", erpnext_sub) + # No commit here: keep SI generation in the webhook's transaction so a later + # Payment Entry failure rolls back the SI too (handle_event retries the event). + sub_doc.process(posting_date=end or getdate(now_datetime())) + except Exception: + frappe.log_error(frappe.get_traceback(), "Stripe: could not generate subscription invoice") + return None + return _find_period_sales_invoice(erpnext_sub, invoice) + + +def _create_payment_entry(si, invoice, settings): + from payments.utils import erpnext_app_import_guard + + with erpnext_app_import_guard(): + from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry + + pe = get_payment_entry("Sales Invoice", si) + pe.reference_no = invoice.get("id") + pe.reference_date = getdate(now_datetime()) + if pe.meta.has_field("stripe_payment_intent"): + pe.stripe_payment_intent = invoice.get("payment_intent") + pe.flags.ignore_permissions = True + pe.insert() + pe.submit() + return pe.name + + def reconcile_one_off(event, settings): """payment_intent.succeeded — backstop for one-off payments. @@ -64,4 +258,8 @@ def handle_setup_intent_succeeded(event, settings): "checkout.session.completed": reconcile_checkout_session, "payment_intent.succeeded": reconcile_one_off, "setup_intent.succeeded": handle_setup_intent_succeeded, + "invoice.paid": reconcile_recurring_invoice, + "invoice.payment_failed": mark_dunning, + "customer.subscription.updated": sync_subscription_status, + "customer.subscription.deleted": sync_subscription_status, } diff --git a/payments/payment_gateways/stripe_subscription_sync.py b/payments/payment_gateways/stripe_subscription_sync.py index cca5a61b4..d7bc8c0a0 100644 --- a/payments/payment_gateways/stripe_subscription_sync.py +++ b/payments/payment_gateways/stripe_subscription_sync.py @@ -56,12 +56,13 @@ def sync_stripe_price(doc, method=None): try: product_id = None + old_price_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) + old_price_id = doc.product_price_id # archive only after the new price is live if not product_id: product_id = client.products.create( @@ -80,6 +81,10 @@ def sync_stripe_price(doc, method=None): # We run on_update (after the row is written), so persist directly. doc.db_set("product_price_id", price.id, update_modified=False) + if old_price_id: + # New price is persisted; safe to archive the old one (prices are immutable). + client.prices.update(old_price_id, {"active": False}) + except Exception: frappe.log_error(frappe.get_traceback(), "Stripe price sync failed") frappe.msgprint( diff --git a/payments/payment_gateways/stripe_utils.py b/payments/payment_gateways/stripe_utils.py index 7a0318aee..b2f311b6e 100644 --- a/payments/payment_gateways/stripe_utils.py +++ b/payments/payment_gateways/stripe_utils.py @@ -144,6 +144,73 @@ def _find_stripe_customer_by_party(client, customer): return None +def get_subscription_plan_details(reference_doctype, reference_docname): + """The (plan, qty) rows behind a subscription's Payment Request.""" + return frappe.get_all( + "Subscription Plan Detail", + filters={"parent": reference_docname, "parenttype": reference_doctype}, + fields=["plan", "qty"], + order_by="idx", + ) + + +def get_subscription_line_items(reference_doctype, reference_docname): + """Stripe subscription items [{price, quantity}] from the synced plan prices.""" + items = [] + for row in get_subscription_plan_details(reference_doctype, reference_docname): + price = frappe.db.get_value("Subscription Plan", row.plan, "product_price_id") + if not price: + frappe.throw( + frappe._("Subscription Plan {0} is not synced to Stripe yet; save the plan first.").format( + row.plan + ) + ) + items.append({"price": price, "quantity": row.qty or 1}) + return items + + +def find_erpnext_subscription(party, plan_names): + """Best-effort match of an active ERPNext Subscription by party + plan set. + + The Stripe subscription is created from a Payment Request, while ERPNext's + Subscription doctype is what generates the recurring Sales Invoices; they are + not linked natively. This finds the Subscription whose plans cover the paid + plans so we can stamp the Stripe id onto it. The webhook later corrects this + authoritatively from the Stripe subscription metadata. + """ + if not party or not plan_names or not frappe.db.exists("DocType", "Subscription"): + return None + candidates = frappe.get_all( + "Subscription", + filters={"party": party, "status": ("in", ["Active", "Trialing", "Trial"])}, + pluck="name", + order_by="creation desc", + ) + for name in candidates: + plans = set( + frappe.get_all( + "Subscription Plan Detail", + filters={"parent": name, "parenttype": "Subscription"}, + pluck="plan", + ) + ) + if set(plan_names).issubset(plans): + return name + return None + + +def link_stripe_subscription(erpnext_subscription, stripe_subscription_id, stripe_customer_id=None): + """Stamp the Stripe ids onto an ERPNext Subscription (the reconciliation link).""" + if not erpnext_subscription: + return + if not frappe.db.has_column("Subscription", "stripe_subscription_id"): + return # custom-field patch not applied yet + values = {"stripe_subscription_id": stripe_subscription_id} + if stripe_customer_id and frappe.db.has_column("Subscription", "stripe_customer_id"): + values["stripe_customer_id"] = stripe_customer_id + frappe.db.set_value("Subscription", erpnext_subscription, values, update_modified=False) + + def get_stripe_settings_for_gateway(payment_gateway_account): """Resolve a Payment Gateway Account name to its Stripe Settings doc. From 962b890c088efca4554b070287af4020f8cafe6c Mon Sep 17 00:00:00 2001 From: Tarunkumar0601 Date: Mon, 29 Jun 2026 13:44:48 +0530 Subject: [PATCH 7/8] feat(stripe): refunds and a webhook retry sweep --- payments/hooks.py | 6 +++ .../stripe_settings/stripe_settings.py | 52 +++++++++++++++--- payments/payment_gateways/stripe_reconcile.py | 54 +++++++++++++++++++ payments/public/js/payment_entry_stripe.js | 45 ++++++++++++++++ 4 files changed, 150 insertions(+), 7 deletions(-) create mode 100644 payments/public/js/payment_entry_stripe.js diff --git a/payments/hooks.py b/payments/hooks.py index 283ffb0f6..5c04c18c4 100644 --- a/payments/hooks.py +++ b/payments/hooks.py @@ -109,6 +109,9 @@ }, } +# include js in doctype views +doctype_js = {"Payment Entry": "public/js/payment_entry_stripe.js"} + # Scheduled Tasks # --------------- @@ -116,6 +119,9 @@ "all": [ "payments.payment_gateways.doctype.razorpay_settings.razorpay_settings.capture_payment", ], + "hourly": [ + "payments.payment_gateways.stripe_reconcile.sweep_pending", + ], } # Testing diff --git a/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py b/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py index 541f7a6cd..4a3c8fb3e 100644 --- a/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py +++ b/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py @@ -275,6 +275,18 @@ def create_setup_intent_for_card(self, data): ) return {"client_secret": intent.client_secret, "setup_intent": intent.id, "customer": customer_id} + def refund_intent(self, payment_intent, amount=None): + """Refund a PaymentIntent. The ledger entry follows via the charge.refunded webhook.""" + client = get_stripe_client(self) + params = {"payment_intent": payment_intent} + if amount: + pi = client.payment_intents.retrieve(payment_intent) + params["amount"] = to_minor_units(amount, pi.currency) + refund = client.refunds.create( + params, {"idempotency_key": idempotency_key("refund", payment_intent, amount or "full")} + ) + return {"refund": refund.id, "status": refund.status} + def create_request(self, data): self.data = frappe._dict(data) self.stripe = get_stripe_client(self) @@ -728,13 +740,6 @@ def get_gateway_controller(doctype, docname, payment_gateway=None): return frappe.db.get_value("Payment Gateway", payment_gateway, "gateway_controller") -def clear_webhook_secret_cache(): - # Single source of the cache key lives in this package's __init__ (the reader). - from payments.payment_gateways.doctype.stripe_settings import clear_cache - - clear_cache() - - def _success_redirect(metadata): """payment-success URL carrying the reference, so the success page can load it.""" dt = (metadata or {}).get("reference_doctype") @@ -744,6 +749,39 @@ def _success_redirect(metadata): return "payment-success" +def clear_webhook_secret_cache(): + # Single source of the cache key lives in this package's __init__ (the reader). + from payments.payment_gateways.doctype.stripe_settings import clear_cache + + clear_cache() + + +@frappe.whitelist() +def refund_payment_entry(payment_entry, amount=None): + """Refund a Stripe-originated Payment Entry. Books are updated by the webhook.""" + pi = frappe.db.get_value("Payment Entry", payment_entry, "stripe_payment_intent") + if not pi: + frappe.throw(_("This Payment Entry has no linked Stripe payment to refund.")) + + settings = _settings_owning_intent(pi) + if not settings: + frappe.throw(_("Could not find the Stripe account that owns this payment.")) + + return settings.refund_intent(pi, flt(amount) if amount else None) + + +def _settings_owning_intent(payment_intent): + """Find which Stripe account a PaymentIntent belongs to (supports multiple accounts).""" + for name in frappe.get_all("Stripe Settings", pluck="name"): + settings = frappe.get_doc("Stripe Settings", name) + try: + get_stripe_client(settings).payment_intents.retrieve(payment_intent) + return settings + except Exception: + continue + return None + + @frappe.whitelist(allow_guest=True) def checkout_success(session_id, gateway): """Return landing for Hosted Checkout — verify the session, then redirect. diff --git a/payments/payment_gateways/stripe_reconcile.py b/payments/payment_gateways/stripe_reconcile.py index 9c900d6a6..9bdb5af99 100644 --- a/payments/payment_gateways/stripe_reconcile.py +++ b/payments/payment_gateways/stripe_reconcile.py @@ -8,6 +8,7 @@ from frappe.utils import flt, getdate, now_datetime from payments.payment_gateways.stripe_utils import ( + from_minor_units, get_stripe_client, link_stripe_subscription, ) @@ -254,6 +255,58 @@ def handle_setup_intent_succeeded(event, settings): return {"status_label": "Processed"} +def process_refund(event, settings): + """charge.refunded — flag the linked Payment Entry with the refund details. + + The Stripe refund is recorded against the Payment Entry as a comment rather + than auto-posting a credit note, so the accounting reversal stays an explicit, + auditable action (avoids silently mutating the ledger from a webhook). + """ + charge = event["data"]["object"] + pi = charge.get("payment_intent") + pe = ( + frappe.db.get_value("Payment Entry", {"stripe_payment_intent": pi, "docstatus": 1}, "name") + if pi + else None + ) + if not pe: + return {"status_label": "Ignored"} + + amount = from_minor_units(charge.get("amount_refunded", 0), charge.get("currency")) + frappe.get_doc("Payment Entry", pe).add_comment( + "Comment", + frappe._("Stripe refund processed: {0} {1} (charge {2}). Post a credit note / reversal if required.").format( + amount, (charge.get("currency") or "").upper(), charge.get("id") + ), + ) + return {"status_label": "Processed", "reference_doctype": "Payment Entry", "reference_name": pe} + + +def sweep_pending(): + """Scheduler: retry webhook events that failed processing (dropped/erroring deliveries).""" + rows = frappe.get_all( + "Stripe Webhook Log", filters={"status": "Failed"}, fields=["name", "stripe_settings", "payload"], limit=50 + ) + for row in rows: + try: + event = frappe.parse_json(row.payload) + settings = ( + frappe.get_doc("Stripe Settings", row.stripe_settings) if row.stripe_settings else None + ) + result = route_event(event, settings) or {} + frappe.db.set_value( + "Stripe Webhook Log", + row.name, + "status", + result.get("status_label", "Processed"), + update_modified=False, + ) + frappe.db.commit() + except Exception: + frappe.db.rollback() + frappe.log_error(frappe.get_traceback(), "Stripe webhook sweep failed") + + _HANDLERS = { "checkout.session.completed": reconcile_checkout_session, "payment_intent.succeeded": reconcile_one_off, @@ -262,4 +315,5 @@ def handle_setup_intent_succeeded(event, settings): "invoice.payment_failed": mark_dunning, "customer.subscription.updated": sync_subscription_status, "customer.subscription.deleted": sync_subscription_status, + "charge.refunded": process_refund, } diff --git a/payments/public/js/payment_entry_stripe.js b/payments/public/js/payment_entry_stripe.js new file mode 100644 index 000000000..524fc96a1 --- /dev/null +++ b/payments/public/js/payment_entry_stripe.js @@ -0,0 +1,45 @@ +// Adds a "Refund via Stripe" action to submitted Payment Entries with a Stripe intent. + +frappe.ui.form.on("Payment Entry", { + refresh(frm) { + if (frm.doc.docstatus === 1 && frm.doc.stripe_payment_intent) { + frm.add_custom_button(__("Refund via Stripe"), () => { + const d = new frappe.ui.Dialog({ + title: __("Refund via Stripe"), + fields: [ + { + fieldname: "amount", + fieldtype: "Currency", + label: __("Amount (leave blank for full refund)"), + }, + ], + primary_action_label: __("Refund"), + primary_action(values) { + d.hide(); + frappe.call({ + method: + "payments.payment_gateways.doctype.stripe_settings.stripe_settings.refund_payment_entry", + args: { + payment_entry: frm.doc.name, + amount: values.amount || null, + }, + freeze: true, + freeze_message: __("Requesting refund from Stripe..."), + callback(r) { + if (r.message) { + frappe.msgprint( + __( + "Stripe refund {0} is {1}. The ledger will update from the webhook.", + [r.message.refund, r.message.status] + ) + ); + } + }, + }); + }, + }); + d.show(); + }); + } + }, +}); From 2a6568e33c1a18597ccc0691ed2f9ac399d50982 Mon Sep 17 00:00:00 2001 From: Tarunkumar0601 Date: Mon, 29 Jun 2026 16:01:48 +0530 Subject: [PATCH 8/8] fix(stripe): settle payment request as administrator on guest checkout return --- .../doctype/stripe_settings/__init__.py | 3 ++ .../stripe_settings/stripe_settings.py | 29 +++++++++++++-- .../stripe_webhook_log.json | 10 +++++- .../payment_gateways/stripe_integration.py | 6 +++- payments/payment_gateways/stripe_reconcile.py | 35 ++++++++++--------- 5 files changed, 63 insertions(+), 20 deletions(-) diff --git a/payments/payment_gateways/doctype/stripe_settings/__init__.py b/payments/payment_gateways/doctype/stripe_settings/__init__.py index 3d32635ad..3f07a8f42 100644 --- a/payments/payment_gateways/doctype/stripe_settings/__init__.py +++ b/payments/payment_gateways/doctype/stripe_settings/__init__.py @@ -59,6 +59,9 @@ def handle_event(event, settings): A prior *Failed* attempt is allowed to reprocess (its log row is reused); any other prior status is a genuine duplicate and is skipped. """ + # The event is signature-verified; run reconciliation as Administrator so the + # handlers can read/write ERPNext docs (the endpoint itself is allow_guest). + frappe.set_user("Administrator") event_id = event["id"] prior = frappe.db.get_value( "Stripe Webhook Log", {"stripe_event_id": event_id}, ["name", "status"], as_dict=True diff --git a/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py b/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py index 4a3c8fb3e..a0622bb38 100644 --- a/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py +++ b/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py @@ -651,7 +651,17 @@ def finalize_checkout_session(self, session_id): "reference_docname": metadata.get("reference_docname"), } ) - self.integration_request.db_set("output", session.get("payment_intent"), update_modified=False) + intent_id = session.get("payment_intent") + if not intent_id and session.get("subscription"): + # Subscription-mode sessions carry no top-level PaymentIntent; pull the one + # from the first invoice so the Payment Entry is refundable via the UI. + sub = client.subscriptions.retrieve( + session["subscription"], {"expand": ["latest_invoice.payment_intent"]} + ) + li = sub.get("latest_invoice") or {} + pi = li.get("payment_intent") if li else None + intent_id = pi.get("id") if pi else None + self.integration_request.db_set("output", intent_id, update_modified=False) self.flags.status_changed_to = "Completed" return self.finalize_request() @@ -697,7 +707,20 @@ def settle_payment_request(self, pr): 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() + # Settle as Administrator: the Guest checkout return / webhook can't read the Sales Invoice. + original_user = frappe.session.user + try: + frappe.set_user("Administrator") + payment_entry = pr.set_as_paid() + finally: + frappe.set_user(original_user) + + # Stamp the PaymentIntent onto the new PE (needed for refunds). + intent_id = getattr(getattr(self, "integration_request", None), "output", None) + if intent_id and payment_entry and payment_entry.meta.has_field("stripe_payment_intent"): + frappe.db.set_value( + "Payment Entry", payment_entry.name, "stripe_payment_intent", intent_id, update_modified=False + ) def finalize_request(self): redirect_to = self.data.get("redirect_to") or None @@ -759,6 +782,8 @@ def clear_webhook_secret_cache(): @frappe.whitelist() def refund_payment_entry(payment_entry, amount=None): """Refund a Stripe-originated Payment Entry. Books are updated by the webhook.""" + # Real-money action: require write access to this specific Payment Entry. + frappe.has_permission("Payment Entry", "write", payment_entry, throw=True) pi = frappe.db.get_value("Payment Entry", payment_entry, "stripe_payment_intent") if not pi: frappe.throw(_("This Payment Entry has no linked Stripe payment to refund.")) diff --git a/payments/payment_gateways/doctype/stripe_webhook_log/stripe_webhook_log.json b/payments/payment_gateways/doctype/stripe_webhook_log/stripe_webhook_log.json index 191569c59..584b7ed6b 100644 --- a/payments/payment_gateways/doctype/stripe_webhook_log/stripe_webhook_log.json +++ b/payments/payment_gateways/doctype/stripe_webhook_log/stripe_webhook_log.json @@ -17,7 +17,8 @@ "reference_name", "section_break_payload", "payload", - "error" + "error", + "retry_count" ], "fields": [ { @@ -84,6 +85,13 @@ "fieldname": "error", "fieldtype": "Long Text", "label": "Error" + }, + { + "default": "0", + "fieldname": "retry_count", + "fieldtype": "Int", + "label": "Retry Count", + "read_only": 1 } ], "index_web_pages_for_search": 1, diff --git a/payments/payment_gateways/stripe_integration.py b/payments/payment_gateways/stripe_integration.py index 41517e0f6..ffa29148e 100644 --- a/payments/payment_gateways/stripe_integration.py +++ b/payments/payment_gateways/stripe_integration.py @@ -86,7 +86,11 @@ def create_subscription_on_stripe(stripe_settings): subscription = client.subscriptions.create( create_args, {"idempotency_key": idempotency_key("sub", pr.name)} ) - stripe_settings.integration_request.db_set("output", subscription.id, update_modified=False) + # Stamp the first invoice's PaymentIntent (not the sub id) so the PE is refundable. + _intent = getattr(getattr(subscription, "latest_invoice", None), "payment_intent", None) + stripe_settings.integration_request.db_set( + "output", _intent.id if _intent else subscription.id, update_modified=False + ) link_stripe_subscription(erpnext_sub, subscription.id, customer_id) if subscription.status in ("active", "trialing"): diff --git a/payments/payment_gateways/stripe_reconcile.py b/payments/payment_gateways/stripe_reconcile.py index 9bdb5af99..78c555642 100644 --- a/payments/payment_gateways/stripe_reconcile.py +++ b/payments/payment_gateways/stripe_reconcile.py @@ -275,36 +275,39 @@ def process_refund(event, settings): amount = from_minor_units(charge.get("amount_refunded", 0), charge.get("currency")) frappe.get_doc("Payment Entry", pe).add_comment( "Comment", - frappe._("Stripe refund processed: {0} {1} (charge {2}). Post a credit note / reversal if required.").format( - amount, (charge.get("currency") or "").upper(), charge.get("id") - ), + frappe._( + "Stripe refund processed: {0} {1} (charge {2}). Post a credit note / reversal if required." + ).format(amount, (charge.get("currency") or "").upper(), charge.get("id")), ) return {"status_label": "Processed", "reference_doctype": "Payment Entry", "reference_name": pe} def sweep_pending(): """Scheduler: retry webhook events that failed processing (dropped/erroring deliveries).""" + MAX_RETRIES = 5 rows = frappe.get_all( - "Stripe Webhook Log", filters={"status": "Failed"}, fields=["name", "stripe_settings", "payload"], limit=50 + "Stripe Webhook Log", + filters={"status": "Failed", "retry_count": ("<", MAX_RETRIES)}, + fields=["name", "stripe_settings", "payload", "retry_count"], + limit=50, ) for row in rows: try: event = frappe.parse_json(row.payload) - settings = ( - frappe.get_doc("Stripe Settings", row.stripe_settings) if row.stripe_settings else None - ) - result = route_event(event, settings) or {} - frappe.db.set_value( - "Stripe Webhook Log", - row.name, - "status", - result.get("status_label", "Processed"), - update_modified=False, - ) - frappe.db.commit() + settings = frappe.get_doc("Stripe Settings", row.stripe_settings) if row.stripe_settings else None + status_label = (route_event(event, settings) or {}).get("status_label", "Processed") except Exception: frappe.db.rollback() frappe.log_error(frappe.get_traceback(), "Stripe webhook sweep failed") + status_label = "Failed" + + # Count the attempt whether the handler threw OR returned Failed, so a + # permanently-failing event ages out of the sweep instead of looping forever. + updates = {"status": status_label} + if status_label == "Failed": + updates["retry_count"] = (row.retry_count or 0) + 1 + frappe.db.set_value("Stripe Webhook Log", row.name, updates, update_modified=False) + frappe.db.commit() _HANDLERS = {