From 328955b5d9f76e8444e9f20a7fe17f90fc146e15 Mon Sep 17 00:00:00 2001 From: Ricardo Dahis <6617207+rdahis@users.noreply.github.com> Date: Wed, 19 Aug 2026 13:32:09 +1000 Subject: [PATCH 1/5] docs: correct CLAUDE.md Git Flow model (branch from main, same commits) (#1054) --- CLAUDE.md | 49 +++++++++++++++++++++++++++++-------------------- 1 file changed, 29 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 540d8e34..5662a69c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,31 +6,40 @@ Canonical reference: https://github.com/basedosdados/backend/wiki/Boas-Pr%C3%A1ticas#segue-o-fluxo ### The environment branches — never commit or push directly -- `main` — production. +- `main` — production. Source of truth: resets flow *from* `main`. - `staging` — pre-production / QA. -- `dev` — integration / testing. First target for new work. +- `dev` — integration / testing. -These three are **parallel, independently maintained** branches, not a linear chain. -Their histories have diverged, so you never merge one environment branch into another to -move a feature — that would drag the whole environment forward. Each feature is promoted -into each environment **selectively**, via its own PR carrying only that feature. +### How work flows +Every feature starts from `main` and is promoted back into the other environments using +**one branch and three PRs** — the same head branch is PR'd into `dev`, `staging`, and +`main`. Merging the same branch into each target puts the *same commit objects* into all +three, so the feature's own commits share SHAs everywhere. Only the merge commits differ, +which is expected and fine. -### Feature workflow — promote the feature, not the environment -1. Cut your feature branch off `dev` (the branch you integrate and test in first). +The environments still drift apart as those merge commits accumulate at different times, so +the team **resets `staging` and `dev` back to `main` roughly every two weeks**. Because +resets flow *from* `main`, a change must reach `main` to survive — anything living only on +`staging` or `dev` is discarded at the next reset. + +### Feature workflow — one branch, three PRs +1. Cut your feature branch off `main` — never off `staging` or `dev`. Name it by intent: `feat/…`, `fix/…`, `chore/…`, `docs/…`, `refactor/…`. - Keep one logical change per branch, with tidy commits — you will cherry-pick them. -2. Open a PR from that branch into `dev`. -3. To promote the same feature to `staging`, cut a new branch off `staging` and - cherry-pick only this feature's commit(s) onto it, then open a PR into `staging`. -4. To promote to `main`, repeat: cut a branch off `main`, cherry-pick the same commit(s), - open a PR into `main`. -5. Result: one clean PR per environment, each carrying only this feature. + One logical change per branch. +2. From that **same branch**, open three PRs: one into `dev`, one into `staging`, one + into `main`. Do not cut a separate branch per target, and do not cherry-pick. +3. Merge with a **merge commit or fast-forward — never squash**. A squash mints a new, + unrelated commit on each branch and breaks the shared history the resets rely on. +4. Timing: a `main`-based branch merges cleanly into `dev`/`staging` when those are + aligned with `main` — in practice, shortly after a reset. The longer since the last + reset, the more of `main`'s accumulated commits the PR will drag along. If a target has + drifted far, wait for the reset rather than forcing a noisy merge. ### Rules for agents working in this repo -- Never commit or push to `main`, `staging`, or `dev` directly. -- Move a feature between environments by cherry-picking it onto a branch cut off the - target — never by merging `dev → staging` or `staging → main`. -- Each promotion branch is cut off its own target, so the PR diff is only this feature. -- One logical change per branch; one PR at a time per target; keep commits clean for cherry-picking. +- Never commit or push to `main`, `staging`, or `dev` directly — always a feature branch + PR. +- Always cut features off `main`, never off `staging` or `dev`. +- Use **one branch for all three PRs**. Never a branch-per-target, never cherry-pick. +- **Never squash-merge.** Merge commit or fast-forward only. +- Never merge one environment branch into another to promote a feature. - Before committing, verify you are on a feature branch: `git branch --show-current`. - Do not push without explicit permission. From cb74d93b6de5d0d1b7b7d54d991c56646649e316 Mon Sep 17 00:00:00 2001 From: Ricardo Dahis <6617207+rdahis@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:43:52 +1000 Subject: [PATCH 2/5] feat: expose the Stripe price id on StripePriceNode (#1044) (cherry picked from commit 62755ffebc96f4e0a7252447aa897a5b87027e56) --- backend/apps/account_payment/graphql.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backend/apps/account_payment/graphql.py b/backend/apps/account_payment/graphql.py index d2ae3d45..febfcc72 100644 --- a/backend/apps/account_payment/graphql.py +++ b/backend/apps/account_payment/graphql.py @@ -49,6 +49,7 @@ class StripePriceNode(DjangoObjectType): _id = ID(name="_id") + stripe_price_id = String() amount = Float() interval = String() trial_period_days = String() @@ -70,6 +71,12 @@ class Meta: def resolve__id(root, info): return root.djstripe_id + def resolve_stripe_price_id(root, info): + # The Stripe price id (e.g. "price_..."), stable and verifiable in the Stripe + # dashboard, unlike the environment-specific djstripe PK exposed as `_id`. Lets the + # website select exactly which prices to sell instead of guessing by amount. + return root.id + def resolve_amount(root, info): if root.unit_amount: return root.unit_amount / 100 From eaeac7596298379be32df8a40601d9bb30156814 Mon Sep 17 00:00:00 2001 From: Ricardo Dahis <6617207+rdahis@users.noreply.github.com> Date: Wed, 5 Aug 2026 17:59:28 +1000 Subject: [PATCH 3/5] fix(account): return Google OAuth to the originating frontend domain (#1046) (cherry picked from commit 5f5854e7796be7f38459813d8be21608315116af) --- backend/apps/account/views.py | 42 ++++++++++++++++++++++++++++++----- backend/custom/environment.py | 25 +++++++++++++++++++++ 2 files changed, 62 insertions(+), 5 deletions(-) diff --git a/backend/apps/account/views.py b/backend/apps/account/views.py index f9641d57..75e8d164 100644 --- a/backend/apps/account/views.py +++ b/backend/apps/account/views.py @@ -22,7 +22,19 @@ from backend.apps.account.signals import send_activation_email from backend.apps.account.token import token_generator -from backend.custom.environment import get_frontend_url +from backend.custom.environment import get_allowed_frontend_origins, get_frontend_url + + +def _safe_frontend_origin(candidate): + """Return `candidate` only if it is an allowed frontend origin, else None. + + Guards the Google OAuth redirect against open-redirect abuse: the success + URL carries a JWT, so the target origin must be on the per-environment + allowlist before it is trusted. + """ + if candidate and candidate in get_allowed_frontend_origins(): + return candidate + return None class AccountActivateView(View): @@ -157,6 +169,16 @@ def get(self, request): state = secrets.token_urlsafe(32) request.session["oauth_state"] = state + # Remember which frontend domain the login started on, so the + # callback returns the user there (pt/en/es live on sibling + # domains). Validated against the allowlist; unknown values are + # dropped and the callback falls back to settings.FRONTEND_URL. + redirect_origin = _safe_frontend_origin(request.GET.get("redirect_origin")) + if redirect_origin: + request.session["frontend_origin"] = redirect_origin + else: + request.session.pop("frontend_origin", None) + auth_url = ( "https://accounts.google.com/o/oauth2/v2/auth?" f"client_id={settings.GOOGLE_OAUTH_CLIENT_ID}&" @@ -202,27 +224,37 @@ def get(self, request): if "oauth_state" in request.session: del request.session["oauth_state"] + # Return the user to the domain they logged in from (set in + # GoogleAuthView); fall back to the static frontend if absent or + # no longer allowlisted. + frontend_base = ( + _safe_frontend_origin(request.session.pop("frontend_origin", None)) + or settings.FRONTEND_URL + ) + token_data = self._exchange_code_for_token(auth_code) if not token_data: logger.error("Falha ao trocar código por token") - error_url = f"{settings.FRONTEND_URL}/user/login?error=auth_failed" + error_url = f"{frontend_base}/user/login?error=auth_failed" return HttpResponseRedirect(error_url) user_info = self._get_user_info(token_data["access_token"]) if not user_info: logger.error("Não foi possível obter informações do usuário") - error_url = f"{settings.FRONTEND_URL}/user/login?error=user_info_failed" + error_url = f"{frontend_base}/user/login?error=user_info_failed" return HttpResponseRedirect(error_url) account = self._create_or_update_account(user_info, token_data.get("id_token")) if account: jwt_token = get_token(account) - frontend_url = f"{settings.FRONTEND_URL}/user/login?login=success&token={jwt_token}&id={account.id}" # noqa: E501 + frontend_url = ( + f"{frontend_base}/user/login?login=success&token={jwt_token}&id={account.id}" # noqa: E501 + ) return HttpResponseRedirect(frontend_url) else: logger.error("Erro ao criar/atualizar conta") - error_url = f"{settings.FRONTEND_URL}/user/login?error=account_creation_failed" + error_url = f"{frontend_base}/user/login?error=account_creation_failed" return HttpResponseRedirect(error_url) except Exception as e: diff --git a/backend/custom/environment.py b/backend/custom/environment.py index c0c14390..7e9b94b4 100644 --- a/backend/custom/environment.py +++ b/backend/custom/environment.py @@ -58,6 +58,31 @@ def get_frontend_url(): return "localhost:3000" +# The three sibling frontends, one locale per domain (see the website's +# next-i18next.config.js): pt -> basedosdados.org, en -> data-basis.org, +# es -> basedelosdatos.org. +FRONTEND_DOMAINS = ("basedosdados.org", "data-basis.org", "basedelosdatos.org") + + +def get_allowed_frontend_origins(): + """Allowed post-login redirect origins (scheme + host), by environment. + + Google OAuth must return the user to the same domain they started on. The + callback picks the redirect target from this allowlist using the + `redirect_origin` the login page sends; anything off the list falls back to + the single static FRONTEND_URL. The allowlist is what makes honoring an + arbitrary redirect origin safe (a JWT rides in the redirect URL, so an + unvalidated origin would be an open redirect that leaks tokens). + """ + if is_prd(): + return {f"https://{d}" for d in FRONTEND_DOMAINS} + if is_stg(): + return {f"https://staging.{d}" for d in FRONTEND_DOMAINS} + if is_dev(): + return {f"https://development.{d}" for d in FRONTEND_DOMAINS} + return {"http://localhost:3000"} + + def production_task(func): """Decorator that avoids function call if it isn't production""" From 10c78d63ccb5e1c6e75148ba0c9829a235d3165e Mon Sep 17 00:00:00 2001 From: Ricardo Dahis <6617207+rdahis@users.noreply.github.com> Date: Wed, 5 Aug 2026 18:40:17 +1000 Subject: [PATCH 4/5] feat(payment): charge card-country regional price at checkout (#1045) (cherry picked from commit e32f8366ea7777085360c06936a0280c9c6ecabd) --- backend/apps/account_payment/graphql.py | 7 + .../apps/account_payment/regional_pricing.py | 138 +++++++++++++ .../account_payment/test_regional_pricing.py | 195 ++++++++++++++++++ backend/apps/account_payment/webhooks.py | 66 ++++++ 4 files changed, 406 insertions(+) create mode 100644 backend/apps/account_payment/regional_pricing.py create mode 100644 backend/apps/account_payment/test_regional_pricing.py diff --git a/backend/apps/account_payment/graphql.py b/backend/apps/account_payment/graphql.py index febfcc72..2d31921d 100644 --- a/backend/apps/account_payment/graphql.py +++ b/backend/apps/account_payment/graphql.py @@ -52,6 +52,7 @@ class StripePriceNode(DjangoObjectType): stripe_price_id = String() amount = Float() interval = String() + region = String() trial_period_days = String() product_name = String() product_slug = String() @@ -86,6 +87,12 @@ def resolve_interval(root, info): if recurring := root.recurring: return recurring.get("interval", "") + def resolve_region(root, info): + # The pricing region this price sells in ("br", "latam", "intl"), from the + # price's `region` metadata. Lets the storefront show the right currency by + # domain, and mirrors the tag the checkout webhook enforces server-side. + return root.metadata.get("region", "") + def resolve_trial_period_days(root, info): if recurring := root.recurring: return recurring.get("trial_period_days", "") diff --git a/backend/apps/account_payment/regional_pricing.py b/backend/apps/account_payment/regional_pricing.py new file mode 100644 index 00000000..30a1fc04 --- /dev/null +++ b/backend/apps/account_payment/regional_pricing.py @@ -0,0 +1,138 @@ +# -*- coding: utf-8 -*- +"""Region-aware pricing: map a card's country to a pricing region and pick the +matching Stripe price. + +Data Basis sells the same products (BD Pro, Chatbot) at different prices by +region: Brazilian cards in BRL, Spanish-speaking Latin America and the wider +world in USD at region-specific tiers. Each Stripe price carries a ``region`` +metadata tag (``br``, ``latam`` or ``intl``); its product carries a ``code`` tag +(``bd_pro``, ``chatbot``) and its recurring block carries the interval +(``month``, ``year``). + +The checkout page decides which price to *show*; this module is the server-side +guardrail that decides which price to *charge*, from the country of the card the +customer actually used. It exists so that switching to a cheaper regional +storefront (e.g. a US customer checking out on the Brazilian domain) does not +change what they pay. + +The functions here are pure: they take Price-like objects and strings and never +touch the database or the Stripe API. The webhook glue that reads the card +country and loads prices lives in ``webhooks.py``. +""" + +from __future__ import annotations + +DEFAULT_REGION = "intl" + +# Spanish-speaking Latin America, as ISO 3166-1 alpha-2 codes. Brazil is its own +# region (``br``); every country not listed here bills at the international +# (``intl``) tier. +_LATAM_COUNTRIES = frozenset( + { + "AR", # Argentina + "BO", # Bolivia + "CL", # Chile + "CO", # Colombia + "CR", # Costa Rica + "CU", # Cuba + "DO", # Dominican Republic + "EC", # Ecuador + "GT", # Guatemala + "HN", # Honduras + "MX", # Mexico + "NI", # Nicaragua + "PA", # Panama + "PE", # Peru + "PY", # Paraguay + "SV", # El Salvador + "UY", # Uruguay + "VE", # Venezuela + } +) + + +def country_to_region(country: str | None) -> str: + """Map an ISO 3166-1 alpha-2 country code to a pricing region. + + Args: + country: Two-letter country code from the payment card + (case-insensitive), or ``None``/empty when unknown. + + Returns: + ``"br"`` for Brazil, ``"latam"`` for Spanish-speaking Latin America, and + ``"intl"`` for everywhere else or when the country is unknown. + """ + if not country: + return DEFAULT_REGION + code = country.strip().upper() + if code == "BR": + return "br" + if code in _LATAM_COUNTRIES: + return "latam" + return DEFAULT_REGION + + +def _price_region(price) -> str: + return (getattr(price, "metadata", None) or {}).get("region", "") + + +def _price_code(price) -> str: + product = getattr(price, "product", None) + if product is None: + return "" + return (getattr(product, "metadata", None) or {}).get("code", "") + + +def _price_interval(price) -> str: + return (getattr(price, "recurring", None) or {}).get("interval", "") + + +def resolve_regional_price_id(prices, original_price_id: str, region: str) -> str: + """Pick the Stripe price id to charge, given the customer's region. + + Given the price the customer checked out with and their card's region, return + the id of the equivalent price (same product ``code`` and billing interval) + tagged for that region. Falls back to ``original_price_id`` whenever a + confident swap cannot be made: the original price is not in ``prices``, it + already matches the region, it lacks a code or interval, or no active sibling + price for the region exists. + + The function never raises and never returns an id that is not present in + ``prices``. This conservatism is deliberate — a wrong or missing regional + price must never block a subscription from being created. + + Args: + prices: Iterable of dj-stripe ``Price``-like objects. Each needs ``.id``, + ``.metadata`` (with ``region``), ``.recurring`` (with ``interval``), + ``.product.metadata`` (with ``code``), and ``.active``. + original_price_id: The Stripe price id encoded in the checkout. + region: Target region from the card country (``br``/``latam``/``intl``). + + Returns: + The Stripe price id to charge — either a region-matched sibling or, when + no confident match exists, ``original_price_id`` unchanged. + """ + prices = list(prices) + + original = next((p for p in prices if p.id == original_price_id), None) + if original is None: + return original_price_id + if _price_region(original) == region: + return original_price_id + + code = _price_code(original) + interval = _price_interval(original) + if not code or not interval: + return original_price_id + + for candidate in prices: + if ( + candidate.id != original_price_id + and getattr(candidate, "active", True) + and _price_code(candidate) == code + and _price_interval(candidate) == interval + and _price_region(candidate) == region + ): + return candidate.id + + return original_price_id diff --git a/backend/apps/account_payment/test_regional_pricing.py b/backend/apps/account_payment/test_regional_pricing.py new file mode 100644 index 00000000..ce76eddc --- /dev/null +++ b/backend/apps/account_payment/test_regional_pricing.py @@ -0,0 +1,195 @@ +# -*- coding: utf-8 -*- +"""Tests for region-aware pricing. + +The decision logic lives in pure functions (``country_to_region`` and +``resolve_regional_price_id``) and is tested directly with stub prices — no +database, no Stripe. The webhook glue (``_regional_price_id``, ``_card_country``) +is tested with mocks for the Stripe API and the price queryset. +""" + +from types import SimpleNamespace +from unittest.mock import patch + +from backend.apps.account_payment.regional_pricing import ( + country_to_region, + resolve_regional_price_id, +) +from backend.apps.account_payment.webhooks import ( + _card_country, + _regional_price_id, +) + + +def _price(price_id, region, code, interval, active=True): + """Build a dj-stripe ``Price``-like stub.""" + return SimpleNamespace( + id=price_id, + active=active, + metadata={"region": region}, + recurring={"interval": interval}, + product=SimpleNamespace(metadata={"code": code}), + ) + + +# A realistic catalogue: BD Pro and Chatbot, monthly and yearly, in three regions. +CATALOGUE = [ + _price("price_br_pro_m", "br", "bd_pro", "month"), + _price("price_latam_pro_m", "latam", "bd_pro", "month"), + _price("price_intl_pro_m", "intl", "bd_pro", "month"), + _price("price_br_pro_y", "br", "bd_pro", "year"), + _price("price_intl_pro_y", "intl", "bd_pro", "year"), + _price("price_br_cb_m", "br", "chatbot", "month"), + _price("price_intl_cb_m", "intl", "chatbot", "month"), +] + + +# --------------------------------------------------------------------------- +# country_to_region +# --------------------------------------------------------------------------- + + +class TestCountryToRegion: + def test_brazil_is_br(self): + assert country_to_region("BR") == "br" + + def test_is_case_insensitive(self): + assert country_to_region("br") == "br" + assert country_to_region("mx") == "latam" + + def test_whitespace_is_trimmed(self): + assert country_to_region(" BR ") == "br" + + def test_spanish_latam_is_latam(self): + for code in ("AR", "MX", "CO", "CL", "PE", "UY", "VE", "DO"): + assert country_to_region(code) == "latam", code + + def test_rest_of_world_is_intl(self): + for code in ("US", "GB", "PT", "DE", "JP", "AU", "ZZ"): + assert country_to_region(code) == "intl", code + + def test_unknown_country_is_intl(self): + assert country_to_region(None) == "intl" + assert country_to_region("") == "intl" + + +# --------------------------------------------------------------------------- +# resolve_regional_price_id +# --------------------------------------------------------------------------- + + +class TestResolveRegionalPriceId: + def test_swaps_br_to_intl_same_product_and_interval(self): + # A US card checking out the Brazilian monthly BD Pro price gets the + # international monthly BD Pro price. + assert resolve_regional_price_id(CATALOGUE, "price_br_pro_m", "intl") == "price_intl_pro_m" + + def test_swaps_br_to_latam(self): + assert ( + resolve_regional_price_id(CATALOGUE, "price_br_pro_m", "latam") == "price_latam_pro_m" + ) + + def test_no_swap_when_region_already_matches(self): + assert ( + resolve_regional_price_id(CATALOGUE, "price_intl_pro_m", "intl") == "price_intl_pro_m" + ) + + def test_keeps_original_when_no_sibling_for_region(self): + # Chatbot has no latam price in the catalogue. + assert resolve_regional_price_id(CATALOGUE, "price_br_cb_m", "latam") == "price_br_cb_m" + + def test_keeps_original_when_not_in_catalogue(self): + assert resolve_regional_price_id(CATALOGUE, "price_unknown", "intl") == "price_unknown" + + def test_interval_must_match(self): + # Yearly original must not be swapped for a monthly sibling. + catalogue = [ + _price("price_br_pro_y", "br", "bd_pro", "year"), + _price("price_intl_pro_m", "intl", "bd_pro", "month"), + ] + assert resolve_regional_price_id(catalogue, "price_br_pro_y", "intl") == "price_br_pro_y" + + def test_product_code_must_match(self): + # A chatbot original must not be swapped for a bd_pro sibling. + catalogue = [ + _price("price_br_cb_m", "br", "chatbot", "month"), + _price("price_intl_pro_m", "intl", "bd_pro", "month"), + ] + assert resolve_regional_price_id(catalogue, "price_br_cb_m", "intl") == "price_br_cb_m" + + def test_inactive_sibling_is_ignored(self): + catalogue = [ + _price("price_br_pro_m", "br", "bd_pro", "month"), + _price("price_intl_pro_m", "intl", "bd_pro", "month", active=False), + ] + assert resolve_regional_price_id(catalogue, "price_br_pro_m", "intl") == "price_br_pro_m" + + def test_untagged_original_still_swaps_to_region(self): + # A price with no region tag ("") differs from the target region, so a + # tagged sibling is still substituted. + catalogue = [ + _price("price_legacy_pro_m", "", "bd_pro", "month"), + _price("price_intl_pro_m", "intl", "bd_pro", "month"), + ] + assert ( + resolve_regional_price_id(catalogue, "price_legacy_pro_m", "intl") == "price_intl_pro_m" + ) + + +# --------------------------------------------------------------------------- +# _card_country +# --------------------------------------------------------------------------- + + +class TestCardCountry: + @patch("backend.apps.account_payment.webhooks.StripePaymentMethod") + def test_reads_card_country(self, pm_cls): + pm_cls.retrieve.return_value = {"card": {"country": "US"}} + assert _card_country("pm_123") == "US" + pm_cls.retrieve.assert_called_once_with("pm_123") + + @patch("backend.apps.account_payment.webhooks.StripePaymentMethod") + def test_returns_none_without_card(self, pm_cls): + pm_cls.retrieve.return_value = {"card": None} + assert _card_country("pm_123") is None + + @patch("backend.apps.account_payment.webhooks.StripePaymentMethod") + def test_returns_none_when_card_missing(self, pm_cls): + pm_cls.retrieve.return_value = {} + assert _card_country("pm_123") is None + + +# --------------------------------------------------------------------------- +# _regional_price_id (webhook glue) +# --------------------------------------------------------------------------- + + +class TestRegionalPriceIdGlue: + def test_no_payment_method_keeps_original(self): + assert _regional_price_id("price_br_pro_m", None, "[ctx] ") == "price_br_pro_m" + + @patch("backend.apps.account_payment.webhooks._card_country") + def test_stripe_error_keeps_original(self, card_country): + card_country.side_effect = RuntimeError("stripe down") + assert _regional_price_id("price_br_pro_m", "pm_123", "[ctx] ") == "price_br_pro_m" + + @patch("backend.apps.account_payment.webhooks.DJStripePrice") + @patch("backend.apps.account_payment.webhooks._card_country") + def test_us_card_swaps_to_intl(self, card_country, price_model): + card_country.return_value = "US" + price_model.objects.all.return_value = CATALOGUE + assert _regional_price_id("price_br_pro_m", "pm_123", "[ctx] ") == "price_intl_pro_m" + + @patch("backend.apps.account_payment.webhooks.DJStripePrice") + @patch("backend.apps.account_payment.webhooks._card_country") + def test_br_card_keeps_brl_price(self, card_country, price_model): + card_country.return_value = "BR" + price_model.objects.all.return_value = CATALOGUE + assert _regional_price_id("price_br_pro_m", "pm_123", "[ctx] ") == "price_br_pro_m" + + @patch("backend.apps.account_payment.webhooks.DJStripePrice") + @patch("backend.apps.account_payment.webhooks._card_country") + def test_unknown_card_country_bills_intl(self, card_country, price_model): + # Stripe returned no country -> intl tier, arbitrage-safe default. + card_country.return_value = None + price_model.objects.all.return_value = CATALOGUE + assert _regional_price_id("price_br_pro_m", "pm_123", "[ctx] ") == "price_intl_pro_m" diff --git a/backend/apps/account_payment/webhooks.py b/backend/apps/account_payment/webhooks.py index 5bd0bf88..8098a1bb 100644 --- a/backend/apps/account_payment/webhooks.py +++ b/backend/apps/account_payment/webhooks.py @@ -14,8 +14,13 @@ from googleapiclient.errors import HttpError from loguru import logger from stripe import Customer as StripeCustomer +from stripe import PaymentMethod as StripePaymentMethod from backend.apps.account.models import Account, Subscription +from backend.apps.account_payment.regional_pricing import ( + country_to_region, + resolve_regional_price_id, +) from backend.apps.account_payment.trials import ( account_eligible_for_bdpro_stripe_trial, account_eligible_for_chatbot_stripe_trial, @@ -863,6 +868,60 @@ def resume_subscription(event: Event, **kwargs): ) +def _card_country(payment_method_id: str) -> str | None: + """Return the ISO country of the card behind a Stripe PaymentMethod. + + Args: + payment_method_id: The `pm_...` id attached to the checkout. + + Returns: + The card's two-letter country code, or `None` if the payment method has + no card (e.g. a non-card method) or Stripe omits the country. + """ + payment_method = StripePaymentMethod.retrieve(payment_method_id) + card = payment_method.get("card") or {} + return card.get("country") + + +def _regional_price_id(original_price_id: str, payment_method, ctx: str) -> str: + """Swap in the region-correct price for the card's country (arbitrage guard). + + Reads the country of the card attached to the checkout and, when a price for + that region exists for the same product and interval, returns it in place of + `original_price_id`. Any failure — no card on the intent, a Stripe lookup + error, or no regional sibling — leaves `original_price_id` untouched, so this + never blocks a subscription from being created. + + Args: + original_price_id: The Stripe price id encoded in the SetupIntent. + payment_method: The `pm_...` id from the SetupIntent, or a falsy value. + ctx: Logging prefix identifying the webhook event. + + Returns: + The Stripe price id to charge. + """ + if not payment_method: + return original_price_id + + try: + country = _card_country(payment_method) + except Exception as e: # noqa: BLE001 — never block checkout on a Stripe/network error. + logger.opt(exception=e).warning( + f"{ctx}Could not read card country for {payment_method!r}; " + "charging the price as checked out." + ) + return original_price_id + + region = country_to_region(country) + chosen = resolve_regional_price_id(DJStripePrice.objects.all(), original_price_id, region) + if chosen != original_price_id: + logger.info( + f"{ctx}Card country {country!r} maps to region {region!r}; " + f"charging {chosen} instead of {original_price_id}." + ) + return chosen + + @webhooks.handler("setup_intent.succeeded") def setup_intent_succeeded(event: Event, **kwargs): """Finish checkout: save the payment method and start the subscription. @@ -909,6 +968,13 @@ def setup_intent_succeeded(event: Event, **kwargs): if not price_id: return + # Arbitrage guard: charge in the currency of the card's country, not the + # currency of the storefront the customer happened to check out from. Swaps + # to the region-matching price when one exists, and keeps the original on any + # uncertainty (see _regional_price_id). The product type is preserved, so the + # chatbot/bd_pro logic below is unaffected by the swap. + price_id = _regional_price_id(price_id, payment_method, ctx) + is_chatbot_price = _price_is_chatbot(price_id) if is_chatbot_price is None: logger.warning(f"{ctx}Price {price_id!r} não encontrado; assinatura não criada.") From ee60b8cff1526e070f382caf17f8b8e3cd249f06 Mon Sep 17 00:00:00 2001 From: Ricardo Dahis <6617207+rdahis@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:30:57 +1000 Subject: [PATCH 5/5] refactor(account): remove legacy Career.team_old and role_old fields (#1048) (cherry picked from commit 0e806c48791702be93e142b0227b442ceaedc6d9) --- backend/apps/account/admin.py | 4 ---- ..._career_team_old_remove_career_role_old.py | 20 +++++++++++++++++++ backend/apps/account/models.py | 2 -- 3 files changed, 20 insertions(+), 6 deletions(-) create mode 100644 backend/apps/account/migrations/0029_remove_career_team_old_remove_career_role_old.py diff --git a/backend/apps/account/admin.py b/backend/apps/account/admin.py index daa12189..844930da 100644 --- a/backend/apps/account/admin.py +++ b/backend/apps/account/admin.py @@ -342,9 +342,7 @@ class RoleAdmin(admin.ModelAdmin): class CareerAdmin(admin.ModelAdmin): list_display = ( "account", - "team_old", "team", - "role_old", "role", "level", "start_at", @@ -352,9 +350,7 @@ class CareerAdmin(admin.ModelAdmin): ) search_fields = ( "account__email", - "team_old", "team__name", - "role_old", "role__name", ) readonly_fields = ("created_at", "updated_at") diff --git a/backend/apps/account/migrations/0029_remove_career_team_old_remove_career_role_old.py b/backend/apps/account/migrations/0029_remove_career_team_old_remove_career_role_old.py new file mode 100644 index 00000000..c06c2720 --- /dev/null +++ b/backend/apps/account/migrations/0029_remove_career_team_old_remove_career_role_old.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("account", "0028_alter_account_uuid"), + ] + + operations = [ + migrations.RemoveField( + model_name="career", + name="team_old", + ), + migrations.RemoveField( + model_name="career", + name="role_old", + ), + ] diff --git a/backend/apps/account/models.py b/backend/apps/account/models.py index 7bd1dbbc..3eaf14e2 100644 --- a/backend/apps/account/models.py +++ b/backend/apps/account/models.py @@ -495,11 +495,9 @@ def __str__(self): class Career(BaseModel): id = models.UUIDField(primary_key=True, default=uuid4) account = models.ForeignKey(Account, on_delete=models.DO_NOTHING, related_name="careers") - team_old = models.CharField("Team (old)", max_length=40, blank=True) team = models.ForeignKey( Team, on_delete=models.DO_NOTHING, related_name="careers", null=True, blank=True ) - role_old = models.CharField("Role (old)", max_length=40, blank=True) role = models.ForeignKey( Role, on_delete=models.DO_NOTHING, related_name="careers", null=True, blank=True )