Skip to content
Closed
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
49 changes: 29 additions & 20 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
4 changes: 0 additions & 4 deletions backend/apps/account/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -342,19 +342,15 @@ class RoleAdmin(admin.ModelAdmin):
class CareerAdmin(admin.ModelAdmin):
list_display = (
"account",
"team_old",
"team",
"role_old",
"role",
"level",
"start_at",
"end_at",
)
search_fields = (
"account__email",
"team_old",
"team__name",
"role_old",
"role__name",
)
readonly_fields = ("created_at", "updated_at")
Expand Down
Original file line number Diff line number Diff line change
@@ -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",
),
]
2 changes: 0 additions & 2 deletions backend/apps/account/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
)
Expand Down
42 changes: 37 additions & 5 deletions backend/apps/account/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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}&"
Expand Down Expand Up @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions backend/apps/account_payment/graphql.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@

class StripePriceNode(DjangoObjectType):
_id = ID(name="_id")
stripe_price_id = String()
amount = Float()
interval = String()
region = String()
trial_period_days = String()
product_name = String()
product_slug = String()
Expand All @@ -70,6 +72,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
Expand All @@ -79,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", "")
Expand Down
138 changes: 138 additions & 0 deletions backend/apps/account_payment/regional_pricing.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading