Skip to content

Commit a0713ef

Browse files
feat(stripe): shared infrastructure for the Stripe refactor
1 parent 90eb56e commit a0713ef

8 files changed

Lines changed: 342 additions & 9 deletions

File tree

payments/hooks.py

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -100,13 +100,11 @@
100100
# ---------------
101101
# Hook on document methods and events
102102

103-
# doc_events = {
104-
# "*": {
105-
# "on_update": "method",
106-
# "on_cancel": "method",
107-
# "on_trash": "method"
108-
# }
109-
# }
103+
doc_events = {
104+
"Subscription Plan": {
105+
"validate": "payments.payment_gateways.stripe_subscription_sync.sync_stripe_price",
106+
},
107+
}
110108

111109
# Scheduled Tasks
112110
# ---------------

payments/patches.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
[pre_model_sync]
2+
3+
[post_model_sync]
4+
payments.patches.add_stripe_custom_fields

payments/patches/__init__.py

Whitespace-only changes.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Copyright (c) Frappe Technologies Pvt. Ltd. and contributors
2+
# License: MIT. See LICENSE
3+
#
4+
# Installs the Stripe integration custom fields (Customer.stripe_customer_id,
5+
# Subscription.stripe_subscription_id / stripe_customer_id,
6+
# Payment Entry.stripe_payment_intent) on existing sites. make_custom_fields()
7+
# is idempotent, so re-running is safe.
8+
9+
from payments.utils import make_custom_fields
10+
11+
12+
def execute():
13+
make_custom_fields()

payments/payment_gateways/doctype/stripe_settings/stripe_settings.json

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,56 @@
265265
"set_only_once": 0,
266266
"translatable": 0,
267267
"unique": 0
268+
},
269+
{
270+
"fieldname": "configuration_section",
271+
"fieldtype": "Section Break",
272+
"label": "Configuration"
273+
},
274+
{
275+
"default": "Hosted Checkout",
276+
"description": "Hosted Checkout redirects to a Stripe-hosted page (recommended, SCA/3DS ready). Embedded Elements renders the card form on this site.",
277+
"fieldname": "checkout_mode",
278+
"fieldtype": "Select",
279+
"label": "Checkout Mode",
280+
"options": "Hosted Checkout\nEmbedded Elements"
281+
},
282+
{
283+
"default": "0",
284+
"description": "If enabled, a Subscription Plan's Stripe price is auto-synced from its ERPNext cost on save.",
285+
"fieldname": "sync_subscription_price",
286+
"fieldtype": "Check",
287+
"label": "Sync Subscription Plan Price to Stripe"
288+
},
289+
{
290+
"fieldname": "configuration_cb",
291+
"fieldtype": "Column Break"
292+
},
293+
{
294+
"default": "Bill From Cycle One",
295+
"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.",
296+
"fieldname": "subscription_billing_model",
297+
"fieldtype": "Select",
298+
"label": "Subscription Billing Model",
299+
"options": "Bill From Cycle One\nCharge Now + Defer First Cycle"
300+
},
301+
{
302+
"fieldname": "webhooks_section",
303+
"fieldtype": "Section Break",
304+
"label": "Webhooks"
305+
},
306+
{
307+
"description": "Stripe webhook signing secret (whsec_...). Required for the webhook endpoint to verify and process events.",
308+
"fieldname": "webhook_secret",
309+
"fieldtype": "Password",
310+
"label": "Webhook Signing Secret"
311+
},
312+
{
313+
"description": "Point a Stripe webhook endpoint at this URL (copy into the Stripe dashboard).",
314+
"fieldname": "webhook_endpoint",
315+
"fieldtype": "Data",
316+
"label": "Webhook URL",
317+
"read_only": 1
268318
}
269319
],
270320
"has_web_view": 0,
@@ -277,7 +327,7 @@
277327
"issingle": 0,
278328
"istable": 0,
279329
"max_attachments": 0,
280-
"modified": "2022-07-24 13:32:14.429916",
330+
"modified": "2026-06-26 00:00:00.000000",
281331
"modified_by": "Administrator",
282332
"module": "Payment Gateways",
283333
"name": "Stripe Settings",
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# Copyright (c) Frappe Technologies Pvt. Ltd. and contributors
2+
# Keeps a Subscription Plan's product_price_id in sync with its ERPNext cost.
3+
4+
import frappe
5+
6+
from payments.payment_gateways.stripe_utils import (
7+
get_stripe_settings_for_gateway,
8+
to_minor_units,
9+
)
10+
11+
INTERVAL_MAP = {"Day": "day", "Week": "week", "Month": "month", "Year": "year"}
12+
13+
14+
def sync_stripe_price(doc, method=None):
15+
"""doc_event on Subscription Plan (erpnext) — owned by the payments app."""
16+
if doc.price_determination not in ("Fixed Rate", "Monthly Rate", "Based On Price List"):
17+
return
18+
if not doc.payment_gateway:
19+
return
20+
21+
settings = get_stripe_settings_for_gateway(doc.payment_gateway)
22+
if not settings:
23+
return # plan is not on a Stripe gateway
24+
25+
if not settings.get("sync_subscription_price"):
26+
return # opt-in disabled on this Stripe account — keep the manual flow
27+
28+
# Per-unit recurring amount. For "Based On Price List" this is resolved from
29+
# the plan's price list at qty=1 (Stripe multiplies by quantity at checkout);
30+
# Fixed/Monthly Rate use the plan's per-interval cost field.
31+
unit_cost = _plan_unit_cost(doc)
32+
if not unit_cost:
33+
if doc.price_determination == "Based On Price List":
34+
frappe.msgprint(
35+
"No rate found in the plan's Price List for its Item, so the "
36+
"Stripe price could not be synced.",
37+
indicator="orange",
38+
alert=True,
39+
)
40+
return
41+
42+
from payments.payment_gateways.stripe_utils import get_stripe_client
43+
44+
client = get_stripe_client(settings)
45+
46+
unit_amount = to_minor_units(unit_cost, doc.currency)
47+
recurring = {
48+
"interval": INTERVAL_MAP[doc.billing_interval],
49+
"interval_count": doc.billing_interval_count,
50+
}
51+
52+
try:
53+
product_id = None
54+
if doc.product_price_id:
55+
existing = client.prices.retrieve(doc.product_price_id)
56+
if _matches(existing, unit_amount, doc.currency, recurring):
57+
return # already in sync — no API write
58+
product_id = existing.product # reuse same Product
59+
client.prices.update(doc.product_price_id, {"active": False}) # archive (prices are immutable)
60+
61+
if not product_id:
62+
product_id = client.products.create(
63+
{"name": doc.plan_name, "metadata": {"erpnext_plan": doc.name}}
64+
).id
65+
66+
price = client.prices.create(
67+
{
68+
"product": product_id,
69+
"unit_amount": unit_amount,
70+
"currency": (doc.currency or "").lower(),
71+
"recurring": recurring,
72+
"metadata": {"erpnext_plan": doc.name},
73+
}
74+
)
75+
doc.product_price_id = price.id # persists: we run on validate
76+
77+
except Exception:
78+
frappe.log_error(frappe.get_traceback(), "Stripe price sync failed")
79+
frappe.msgprint(
80+
"Could not sync this plan's price to Stripe; the Product Price ID " "may be stale. Please retry.",
81+
indicator="orange",
82+
alert=True,
83+
)
84+
85+
86+
def _matches(price, unit_amount, currency, recurring):
87+
return bool(
88+
price.get("active")
89+
and price.unit_amount == unit_amount
90+
and (price.currency or "").upper() == (currency or "").upper()
91+
and price.get("recurring")
92+
and price.recurring.interval == recurring["interval"]
93+
and price.recurring.interval_count == recurring["interval_count"]
94+
)
95+
96+
97+
def _plan_unit_cost(doc):
98+
"""Per-unit recurring amount in the plan's currency (qty=1).
99+
100+
Stripe stores a single unit price and multiplies by quantity at checkout,
101+
so we always push the qty=1 rate.
102+
"""
103+
if doc.price_determination == "Based On Price List":
104+
from payments.utils import erpnext_app_import_guard
105+
106+
with erpnext_app_import_guard():
107+
from erpnext.accounts.doctype.subscription_plan.subscription_plan import get_plan_rate
108+
109+
return get_plan_rate(doc.name, quantity=1)
110+
# Fixed Rate / Monthly Rate carry a per-interval cost on the plan itself.
111+
return doc.cost
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
# Copyright (c) Frappe Technologies Pvt. Ltd. and contributors
2+
# License: MIT. See LICENSE
3+
#
4+
# Shared helpers for the Stripe integration. Everything that more than one
5+
# Stripe module needs (the client, amount conversion, idempotency keys, the
6+
# settings-resolution chain) lives here so the charge, subscription, sync and
7+
# webhook code all agree on the same rules.
8+
9+
import hashlib
10+
11+
import frappe
12+
from frappe.utils import flt
13+
14+
# Pin the Stripe API version to the one bundled with stripe~=10.12 so behaviour
15+
# does not drift if the account's default version is bumped in the dashboard.
16+
STRIPE_API_VERSION = "2024-06-20"
17+
18+
# Stripe takes amounts in the smallest currency unit (cents), except
19+
# zero-decimal currencies which are already whole units.
20+
# https://docs.stripe.com/currencies#zero-decimal
21+
ZERO_DECIMAL_CURRENCIES = {
22+
"BIF",
23+
"CLP",
24+
"DJF",
25+
"GNF",
26+
"JPY",
27+
"KMF",
28+
"KRW",
29+
"MGA",
30+
"PYG",
31+
"RWF",
32+
"UGX",
33+
"VND",
34+
"VUV",
35+
"XAF",
36+
"XOF",
37+
"XPF",
38+
}
39+
40+
41+
def get_stripe_client(stripe_settings):
42+
"""Return a Stripe client bound to this Stripe Settings doc.
43+
44+
stripe_settings may be a doc or the docname of a "Stripe Settings" record.
45+
46+
A fresh stripe.StripeClient is built per call, carrying its own api key,
47+
pinned api version and http client. This keeps the credentials isolated to
48+
the caller: mutating module-level stripe.api_key / stripe.api_version races
49+
across threads, so two concurrent requests for different Stripe accounts
50+
could charge the wrong account. Route every Stripe call through this client.
51+
"""
52+
import stripe
53+
54+
if isinstance(stripe_settings, str):
55+
stripe_settings = frappe.get_doc("Stripe Settings", stripe_settings)
56+
57+
return stripe.StripeClient(
58+
stripe_settings.get_password("secret_key", raise_exception=False),
59+
stripe_version=STRIPE_API_VERSION,
60+
http_client=stripe.http_client.RequestsClient(),
61+
)
62+
63+
64+
def to_minor_units(amount, currency):
65+
"""Convert a human amount to the integer Stripe expects (e.g. 12.50 USD -> 1250)."""
66+
if (currency or "").upper() in ZERO_DECIMAL_CURRENCIES:
67+
return int(round(flt(amount)))
68+
return int(round(flt(amount) * 100))
69+
70+
71+
def from_minor_units(amount, currency):
72+
"""Inverse of to_minor_units (e.g. 1250 USD -> 12.50)."""
73+
if (currency or "").upper() in ZERO_DECIMAL_CURRENCIES:
74+
return flt(amount)
75+
return flt(amount) / 100.0
76+
77+
78+
def idempotency_key(*parts):
79+
"""Deterministic Stripe idempotency key derived from ERPNext identifiers.
80+
81+
Same inputs -> same key, so a retried create collapses to one Stripe object
82+
(Stripe dedupes idempotency keys for 24h). Keep the inputs stable per logical
83+
operation (e.g. the reference docname + amount), never a timestamp/random.
84+
"""
85+
raw = ":".join(str(p) for p in parts if p is not None)
86+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
87+
88+
89+
def get_stripe_settings_for_gateway(payment_gateway_account):
90+
"""Resolve a Payment Gateway Account name to its Stripe Settings doc.
91+
92+
Walks the same chain as get_gateway_controller() in stripe_settings.py:
93+
Payment Gateway Account -> Payment Gateway -> Stripe Settings
94+
Returns None when the account is not backed by Stripe Settings.
95+
"""
96+
pg = frappe.db.get_value("Payment Gateway Account", payment_gateway_account, "payment_gateway")
97+
if not pg:
98+
return None
99+
gw = frappe.db.get_value("Payment Gateway", pg, ["gateway_settings", "gateway_controller"], as_dict=True)
100+
if not gw or gw.gateway_settings != "Stripe Settings":
101+
return None
102+
return frappe.get_doc("Stripe Settings", gw.gateway_controller)

payments/utils/utils.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,13 +155,68 @@ def make_custom_fields():
155155
"reqd": 1,
156156
"insert_after": "disabled",
157157
}
158-
]
158+
],
159+
# Stripe integration links (see payments.payment_gateways.stripe_*)
160+
"Customer": [
161+
{
162+
"fieldname": "stripe_customer_id",
163+
"fieldtype": "Data",
164+
"label": "Stripe Customer ID",
165+
"read_only": 1,
166+
"no_copy": 1,
167+
"print_hide": 1,
168+
"insert_after": "default_currency",
169+
}
170+
],
171+
"Subscription": [
172+
{
173+
"fieldname": "stripe_subscription_id",
174+
"fieldtype": "Data",
175+
"label": "Stripe Subscription ID",
176+
"read_only": 1,
177+
"no_copy": 1,
178+
"print_hide": 1,
179+
"insert_after": "status",
180+
},
181+
{
182+
"fieldname": "stripe_customer_id",
183+
"fieldtype": "Data",
184+
"label": "Stripe Customer ID",
185+
"read_only": 1,
186+
"no_copy": 1,
187+
"print_hide": 1,
188+
"insert_after": "stripe_subscription_id",
189+
},
190+
],
191+
"Payment Entry": [
192+
{
193+
"fieldname": "stripe_payment_intent",
194+
"fieldtype": "Data",
195+
"label": "Stripe Payment Intent",
196+
"read_only": 1,
197+
"no_copy": 1,
198+
"print_hide": 1,
199+
"insert_after": "reference_no",
200+
}
201+
],
159202
}
160203

161204
create_custom_fields(custom_fields)
162205

163206

164207
def delete_custom_fields():
208+
# Stripe integration custom fields on ERPNext doctypes
209+
if "erpnext" in frappe.get_installed_apps():
210+
click.secho("* Uninstalling Stripe Custom Fields")
211+
stripe_custom_fields = {
212+
"Customer": ("stripe_customer_id",),
213+
"Subscription": ("stripe_subscription_id", "stripe_customer_id"),
214+
"Payment Entry": ("stripe_payment_intent",),
215+
}
216+
for dt, fieldnames in stripe_custom_fields.items():
217+
frappe.db.delete("Custom Field", {"dt": dt, "fieldname": ("in", fieldnames)})
218+
frappe.clear_cache(doctype=dt)
219+
165220
if not frappe.get_meta("Web Form").has_field("payments_tab"):
166221
return
167222

0 commit comments

Comments
 (0)