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/__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.json b/payments/payment_gateways/doctype/stripe_settings/stripe_settings.json index b1bd1c3e6..50094187d 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": "First-charge behaviour when a Payment Request starts a subscription. Bill From Cycle One: Stripe raises the first invoice immediately for the plan price \u00d7 quantity. Charge Now + Defer First Cycle: charges the Payment Request grand total once now and defers the subscription's first recurring invoice to the next billing 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/doctype/stripe_settings/stripe_settings.py b/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py index a73695a46..f71758d9b 100644 --- a/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py +++ b/payments/payment_gateways/doctype/stripe_settings/stripe_settings.py @@ -1,15 +1,23 @@ # Copyright (c) 2017, Frappe Technologies and contributors # License: MIT. See LICENSE +import hmac 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_or_create_customer, + get_stripe_client, + idempotency_key, + to_minor_units, +) from payments.utils import create_payment_gateway currency_wise_minimum_charge_amount = { @@ -160,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: @@ -169,7 +188,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 +211,70 @@ 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 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) - 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: + 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_charge_on_stripe() + return self.create_payment_intent_on_stripe() except Exception: frappe.log_error(frappe.get_traceback()) @@ -216,42 +288,395 @@ 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. + + Idempotent per reference: a page reload or network retry reuses the open + PaymentIntent instead of orphaning Integration Requests and abandoning PIs. + """ + client = get_stripe_client(self) + + reused = self._reuse_open_checkout_intent(client, data) + if reused: + return reused + + 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"}, + } + ) + integration_request.db_set("output", intent.id, update_modified=False) + return {"client_secret": intent.client_secret, "payment_intent": intent.id} + + def _reuse_open_checkout_intent(self, client, data): + """Return an existing open PaymentIntent for this reference, if still reusable. + + A reload/retry hits create_payment_intent_for_checkout again; without this + each call mints a fresh PaymentIntent + Integration Request, leaving orphans. + Only an unconfirmed intent for the same amount is reused. + """ + reference_doctype = data.get("reference_doctype") + reference_docname = data.get("reference_docname") + if not (reference_doctype and reference_docname): + return None + rows = frappe.get_all( + "Integration Request", + filters={ + "integration_request_service": "Stripe", + "status": "Queued", + "reference_doctype": reference_doctype, + "reference_docname": reference_docname, + }, + fields=["output"], + order_by="creation desc", + limit=1, + ) + if not rows or not rows[0].output: + return None + try: + intent = client.payment_intents.retrieve(rows[0].output) + except Exception: + return None + if intent.get("status") not in ("requires_payment_method", "requires_confirmation"): + return None + if intent.get("amount") != to_minor_units(data.amount, data.currency): + return None + 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). + + 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: - 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, + 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: + 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 charge.captured is True: - self.integration_request.db_set("status", "Completed", update_modified=False) - self.flags.status_changed_to = "Completed" + 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 + ) - else: - frappe.log_error(charge.failure_message, "Stripe Payment not completed") + 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) - except Exception: - frappe.log_error(frappe.get_traceback()) + 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.update(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). + 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) + 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") + }, + }, + "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 + + 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 self.integration_request.status == "Completed": + return {"redirect_to": _success_redirect(metadata), "status": "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"} + + 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 +705,46 @@ 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 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") + 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. + """ + if not frappe.db.exists("Stripe Settings", gateway): + frappe.local.response["type"] = "redirect" + frappe.local.response["location"] = "/payment-failed" + return + + 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..ef23dd14e 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,174 @@ # 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 MagicMock, patch +import frappe +import stripe +from frappe.tests.utils import FrappeTestCase -class TestStripeSettings(unittest.TestCase): - pass +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" + +# Real Stripe PaymentIntent service class. Specing service mocks against it makes a +# call to a method that does not exist (e.g. the old resource-API .modify) raise +# AttributeError instead of MagicMock silently fabricating it. +_PAYMENT_INTENT_SERVICE = type(stripe.StripeClient("sk_test_dummy").payment_intents) + + +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()) + + 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 = MagicMock(spec=_PAYMENT_INTENT_SERVICE) + 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.update.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.update.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/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..4e2ac40a5 --- /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\nPending\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..e5ced4632 --- /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", "Pending", "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..26cfdcc0a --- /dev/null +++ b/payments/payment_gateways/stripe_reconcile.py @@ -0,0 +1,78 @@ +# 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 _status_label(result): + """Log label from a finaliser result. Async (bank-debit) sessions settle + later, so surface Pending instead of masking it as Processed.""" + status = (result or {}).get("status") + if status == "Failed": + return "Failed" + if status == "Pending": + return "Pending" + return "Processed" + + +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": _status_label(result), + "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": _status_label(result), + "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_subscription_sync.py b/payments/payment_gateways/stripe_subscription_sync.py new file mode 100644 index 000000000..cca5a61b4 --- /dev/null +++ b/payments/payment_gateways/stripe_subscription_sync.py @@ -0,0 +1,117 @@ +# 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 frappe.utils import cint + +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": cint(doc.billing_interval_count) or 1, + } + + 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..96e3ef4d4 --- /dev/null +++ b/payments/payment_gateways/stripe_utils.py @@ -0,0 +1,173 @@ +# 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 +import stripe +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_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: + # Cached id is stale / deleted at Stripe; log a trace and fall through to recreate. + frappe.log_error( + f"Stale Stripe customer id {existing} for {customer}", "Stripe customer resolution" + ) + + # 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 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 + return None + + +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. + """ + payment_gateway = frappe.db.get_value( + "Payment Gateway Account", payment_gateway_account, "payment_gateway" + ) + if not payment_gateway: + return None + gateway = frappe.db.get_value( + "Payment Gateway", payment_gateway, ["gateway_settings", "gateway_controller"], as_dict=True + ) + if not gateway or gateway.gateway_settings != "Stripe Settings": + return None + if not gateway.gateway_controller: + # No explicit controller: use the sole Stripe Settings record, only if unambiguous. + if frappe.db.count("Stripe Settings") != 1: + return None + name = frappe.get_all("Stripe Settings", pluck="name", limit=1)[0] + return frappe.get_doc("Stripe Settings", name) + return frappe.get_doc("Stripe Settings", gateway.gateway_controller) diff --git a/payments/templates/includes/stripe_checkout.js b/payments/templates/includes/stripe_checkout.js index 7f37e96ba..a788b628b 100644 --- a/payments/templates/includes/stripe_checkout.js +++ b/payments/templates/includes/stripe_checkout.js @@ -1,86 +1,164 @@ -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; +var paymentIntentId; -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); +} + +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; + paymentIntentId = r.message.payment_intent; + elements = stripe.elements({ clientSecret: clientSecret }); + paymentElement = elements.create('payment', { + defaultValues: { + billingDetails: { + name: {{ payer_name | tojson }}, + email: {{ payer_email | tojson }} + } + } + }); + paymentElement.mount('#card-element'); + } + }); +} - } else if (result.error) { - $('.error').html(result.error.message); - $('.error').show() +function confirmPayment() { + if (!elements || !clientSecret) { + showError(__('Payment is still initialising. Please wait a moment.')); + return; } + setSubmitting(true); + recordConsentThen(doConfirm); } -card.on('change', function(event) { - var displayError = document.getElementById('card-errors'); - if (event.error) { - displayError.textContent = event.error.message; - } else { - displayError.textContent = ''; +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(); } + }); +} -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() +function doConfirm() { + stripe.confirmPayment({ + elements: elements, + redirect: 'if_required', + confirmParams: { + receipt_email: $('input[name=cardholder-email]').val() } - stripe.createToken(card, extraDetails).then(setOutcome); - }) + }).then(function (result) { + if (result.error) { + showError(result.error.message); + $('.error').show(); + setSubmitting(false); + return; + } + + 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" || 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 { + showError(__('The payment could not be completed.')); + setSubmitting(false); + } + }); +} + +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.css b/payments/templates/pages/stripe_checkout.css index 20746bbbe..59b0e6c00 100644 --- a/payments/templates/pages/stripe_checkout.css +++ b/payments/templates/pages/stripe_checkout.css @@ -139,4 +139,42 @@ #card-errors { margin-top: 6px; } -} \ No newline at end of file +} + +/* Card Details hosts Stripe's multi-field Payment Element (Link + card + save-card), + which sizes itself. Override the single-line .field box so it is never clipped and + the Pay button drops below it instead of overlapping. */ +.stripe label.card-details { + height: auto; + line-height: normal; +} + +.stripe label.card-details > span { + width: 100%; + float: none; + margin-bottom: 8px; +} + +#card-element { + float: none; + width: 100%; + height: auto; + background: #fff; + padding: 12px; + border-radius: 8px; + box-shadow: 0 1px 3px 0 #e6ebf1; +} + +.stripe button { + clear: both; +} + +.stripe label.save-card { + height: auto; + line-height: normal; +} + +.stripe label.save-card > span { + width: auto; + float: none; +} diff --git a/payments/templates/pages/stripe_checkout.html b/payments/templates/pages/stripe_checkout.html index b8b56065f..385eabea2 100644 --- a/payments/templates/pages/stripe_checkout.html +++ b/payments/templates/pages/stripe_checkout.html @@ -39,13 +39,19 @@

-
+ +
+
diff --git a/payments/templates/pages/stripe_checkout.py b/payments/templates/pages/stripe_checkout.py index 7f4a30e0b..b644cc96c 100644 --- a/payments/templates/pages/stripe_checkout.py +++ b/payments/templates/pages/stripe_checkout.py @@ -41,13 +41,37 @@ 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: + plan = frappe.db.get_value( + "Subscription Plan", + payment_plans[0].plan, + ["billing_interval", "billing_interval_count"], + as_dict=True, + ) + if plan: + singular = { + "Day": _("daily"), + "Week": _("weekly"), + "Month": _("monthly"), + "Year": _("yearly"), + } + plural = {"Day": _("days"), "Week": _("weeks"), "Month": _("months"), "Year": _("years")} + billing_interval_count = cint(plan.billing_interval_count) or 1 + if billing_interval_count == 1: + recurrence = singular.get(plan.billing_interval, "") + else: + recurrence = _("every {0} {1}").format( + billing_interval_count, plural.get(plan.billing_interval, "") + ) + + if recurrence: + context["amount"] = context["amount"] + " " + recurrence else: frappe.redirect_to_message( _("Some information is missing"), @@ -69,11 +93,119 @@ 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) + 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") + + @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) + # 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) + 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 + + +@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. - data.update({"stripe_token_id": stripe_token_id}) + 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, + 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. + 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) 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