Skip to content
Open
21 changes: 14 additions & 7 deletions payments/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,17 @@
# ---------------
# 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",
},
}

# include js in doctype views
doctype_js = {"Payment Entry": "public/js/payment_entry_stripe.js"}

# Scheduled Tasks
# ---------------
Expand All @@ -115,6 +119,9 @@
"all": [
"payments.payment_gateways.doctype.razorpay_settings.razorpay_settings.capture_payment",
],
"hourly": [
"payments.payment_gateways.stripe_reconcile.sweep_pending",
],
}

# Testing
Expand Down
4 changes: 4 additions & 0 deletions payments/patches.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[pre_model_sync]

[post_model_sync]
payments.patches.add_stripe_custom_fields
Empty file added payments/patches/__init__.py
Empty file.
13 changes: 13 additions & 0 deletions payments/patches/add_stripe_custom_fields.py
Original file line number Diff line number Diff line change
@@ -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()
130 changes: 130 additions & 0 deletions payments/payment_gateways/doctype/stripe_settings/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
# 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.
"""
# 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
)
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)
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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",
Expand Down
Loading