Per-user retirement jurisdiction from Stripe billing address (#119)#134
Conversation
… billing address Closes the autopilot half of regen-network#119. Subscribers had no way to set their retirement jurisdiction — every scheduled retirement used the hardcoded config.defaultJurisdiction ("US"), which made retirement records inaccurate for non-US subscribers. This wires the resolution chain for the on-chain side without requiring any new UI: 1. Explicit jurisdiction param (MCP tool callers) — already worked 2. user.country — NEW: synced from Stripe billing address at checkout 3. config.defaultJurisdiction — fallback Changes: - db.ts: ALTER TABLE users ADD COLUMN country (ISO 3166-1 alpha-2, nullable). Migration follows the existing pattern. - db.ts: User type gains country: string | null. - db.ts: setUserCountry / setUserCountryIfMissing / getUserCountryBySubscriberId helpers, plus a normalizeCountryCode validator that accepts alpha-2 ("US") and sub-national ("US-OR") forms. Invalid input is rejected, never persisted. - routes.ts: checkout.session.completed webhook reads session.customer_details.address.country and sets it on the user with setUserCountryIfMissing — never overwrites a value the user set explicitly. - retire-subscriber.ts: resolves subscriberJurisdiction once at the top of retireForSubscriber from getUserCountryBySubscriberId(...) ?? config.defaultJurisdiction, then uses it in the two MsgSend / MsgBuyDirect retirement paths (replacing the two hardcoded config.defaultJurisdiction sites). Test: 11 new vitest cases — code normalization (lowercase → upper, sub-national pass-through, invalid → null), if-missing semantics (set when null, no-overwrite, no-op on invalid, no-op on empty), and getUserCountryBySubscriberId join behavior. Full suite: 60/60 passing. Out of scope (deferred to follow-up PR — flagged in regen-network#119): - Dashboard settings UI for editing country after signup. The POST /profile/display-name pattern can be mirrored, but the form to call it is design work. - customer.updated webhook sync. Would catch billing-address edits in the customer portal, but requires enabling that event in the Stripe webhook config. Refs: regen-network#119 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Code Review
This pull request introduces functionality to track and use a user's country for retirement jurisdictions. It includes a database migration to add a country column to the users table, helper functions for normalizing and setting country codes, and logic to sync country data from Stripe billing addresses. The retirement service was updated to prioritize the user's country over the default system jurisdiction. Feedback was provided regarding an inaccurate docstring for the getUserCountryBySubscriberId function, which incorrectly describes its fallback behavior and return type.
| * Resolve the retirement jurisdiction for a subscriber per #119: | ||
| * 1. user.country (explicit setting) | ||
| * 2. defaultFallback (typically config.defaultJurisdiction) | ||
| * Returns the resolved code (always non-null because fallback is required). | ||
| */ |
There was a problem hiding this comment.
The docstring for getUserCountryBySubscriberId is inaccurate. It claims to return a resolved code that is "always non-null because fallback is required," but the function signature returns string | null and it does not implement any fallback logic (the fallback is correctly handled by the caller in retire-subscriber.ts). The documentation should be updated to accurately reflect the function's behavior.
/**
* Get the user's country code for a subscriber.
* Returns the ISO 3166-1 alpha-2 code (or sub-national code) if set, otherwise null.
*/
Closes the autopilot half of #119.
Problem
Subscribers had no way to set their retirement jurisdiction — every scheduled retirement used the hardcoded
config.defaultJurisdiction("US"), making on-chain retirement records inaccurate for non-US subscribers. Marie raised this in the issue.What this PR does
Wires the resolution chain for the on-chain side without requiring any new UI. Stripe already collects the billing-address country during Checkout — we just plumb it through.
Resolution order (per the issue spec):
jurisdictionparam (MCP tool callers) — already workeduser.country— new: synced from Stripe billing address at checkoutconfig.defaultJurisdiction— fallbackChanges
src/server/db.tsALTER TABLE users ADD COLUMN countrymigration (ISO 3166-1 alpha-2, nullable)Usertype gainscountry: string | nullsetUserCountry/setUserCountryIfMissing/getUserCountryBySubscriberIdhelpersnormalizeCountryCodevalidator: accepts\"US\"and sub-national\"US-OR\", rejects invalid inputsrc/server/routes.tscheckout.session.completedwebhook readssession.customer_details.address.countryand callssetUserCountryIfMissingso a Stripe-provided value never overwrites an explicit user settingsrc/services/retire-subscriber.tssubscriberJurisdictiononce at the top ofretireForSubscriber(getUserCountryBySubscriberId(...) ?? config.defaultJurisdiction)config.defaultJurisdictionsites in theMsgSendandMsgBuyDirectretirement pathssrc/__tests__/user-country.test.ts— 11 new testsOut of scope (deliberately, flagged in commit)
POST /profile/display-namepattern can be mirrored, but the form to call it is design work — better as its own PR.customer.updatedwebhook sync for billing-address edits in the customer portal. Requires enabling that event in the Stripe webhook config.Test plan
npm run typecheck— cleannpm test— 60/60 passing (49 prior + 11 new)npm run build— cleanaddressfor the active price IDs (needed for the webhook sync to populate)countrycolumn appears, no data loss)Refs: #119
🤖 Generated with Claude Code