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..044cac46f --- /dev/null +++ b/payments/payment_gateways/stripe_subscription_sync.py @@ -0,0 +1,129 @@ +# 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, + idempotency_key, + 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}}, + {"idempotency_key": idempotency_key("stripe_product", 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}, + }, + { + "idempotency_key": idempotency_key( + "stripe_price", + doc.name, + product_id, + unit_amount, + (doc.currency or "").lower(), + recurring["interval"], + recurring["interval_count"], + ) + }, + ) + # 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..dda27ebe1 --- /dev/null +++ b/payments/payment_gateways/stripe_utils.py @@ -0,0 +1,103 @@ +# 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) + # Not Stripe, or relies on the naming-convention fallback (no explicit controller). + if not gw or gw.gateway_settings != "Stripe Settings" or not gw.gateway_controller: + return None + return frappe.get_doc("Stripe Settings", gw.gateway_controller) 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"), 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