-
Notifications
You must be signed in to change notification settings - Fork 412
feat(stripe): shared infrastructure for the stripe refactor #237
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
Tarunkumar0601
wants to merge
2
commits into
frappe:develop
Choose a base branch
from
aerele:feat/stripe-shared-infra
base: develop
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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() |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
||
| 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, | ||
| } | ||
|
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, | ||
| ) | ||
|
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 | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.