Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 8 additions & 7 deletions payments/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------
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()
127 changes: 127 additions & 0 deletions payments/payment_gateways/doctype/stripe_settings/__init__.py
Original file line number Diff line number Diff line change
@@ -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
Comment thread
greptile-apps[bot] marked this conversation as resolved.

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 []
Comment thread
greptile-apps[bot] marked this conversation as resolved.


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": "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,
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