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()
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
129 changes: 129 additions & 0 deletions payments/payment_gateways/stripe_subscription_sync.py
Original file line number Diff line number Diff line change
@@ -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
Comment thread
Tarunkumar0601 marked this conversation as resolved.

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

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,
)
Comment thread
Tarunkumar0601 marked this conversation as resolved.


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
103 changes: 103 additions & 0 deletions payments/payment_gateways/stripe_utils.py
Original file line number Diff line number Diff line change
@@ -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)
22 changes: 16 additions & 6 deletions payments/templates/pages/stripe_checkout.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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"),
Expand Down
Loading