feat(paykit): add Stripe currency support#199
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub. |
📝 WalkthroughWalkthroughAdds configurable multi-currency support (USD/EUR) to PayKit. Introduces a ChangesMulti-Currency (EUR) Support
Sequence Diagram(s)sequenceDiagram
participant CLI
participant createContext
participant normalizeSchema
participant syncProducts
participant insertProductVersion
participant StripeProvider
CLI->>createContext: options { stripe: { currency: "eur" } }
createContext->>normalizeSchema: products, { priceCurrency: "eur" }
normalizeSchema-->>createContext: NormalizedSchema (priceCurrency: "eur" on paid plans)
CLI->>syncProducts: ctx
syncProducts->>syncProducts: planChanged? (includes priceCurrency diff)
syncProducts->>insertProductVersion: { priceCurrency: "eur", ... }
insertProductVersion-->>syncProducts: StoredProduct (priceCurrency: "eur")
syncProducts->>StripeProvider: syncProducts({ priceCurrency: "eur", ... })
StripeProvider->>StripeProvider: prices.create({ currency: "eur", ... })
StripeProvider-->>syncProducts: providerProduct
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
packages/paykit/src/types/schema.ts (1)
432-432: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winUse
DEFAULT_STRIPE_CURRENCYinstead of hardcoded"usd"in normalization defaults.Line 432 duplicates the default currency literal. If the shared Stripe default changes, this path can silently diverge and produce inconsistent normalized hashes/sync behavior.
Proposed patch
- priceCurrency: exportedPlan.price ? (input?.priceCurrency ?? "usd") : null, + priceCurrency: exportedPlan.price + ? (input?.priceCurrency ?? DEFAULT_STRIPE_CURRENCY) + : null,+import { DEFAULT_STRIPE_CURRENCY } from "../stripe/currency";🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/paykit/src/types/schema.ts` at line 432, Replace the hardcoded "usd" string in the priceCurrency assignment with the DEFAULT_STRIPE_CURRENCY constant to ensure the default currency value is consistent across the codebase. This prevents silent divergence if the shared Stripe default currency changes in the future, maintaining consistent normalized hashes and sync behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/paykit/src/core/validate-options.ts`:
- Around line 33-35: In the currency validation block in validate-options.ts,
replace the truthy check on options.stripe.currency with a nullish check that
specifically tests for null or undefined rather than falsy values. This ensures
that empty strings are still passed to assertValidStripeCurrency for validation
instead of being skipped, preventing invalid empty currency values from leaking
downstream where defaulting logic uses nullish checks.
---
Nitpick comments:
In `@packages/paykit/src/types/schema.ts`:
- Line 432: Replace the hardcoded "usd" string in the priceCurrency assignment
with the DEFAULT_STRIPE_CURRENCY constant to ensure the default currency value
is consistent across the codebase. This prevents silent divergence if the shared
Stripe default currency changes in the future, maintaining consistent normalized
hashes and sync behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: 137cd9c9-3788-4547-be99-48180ac5a2a5
📒 Files selected for processing (23)
e2e/cli/push.test.tspackages/paykit/src/cli/__tests__/format.test.tspackages/paykit/src/cli/utils/format.tspackages/paykit/src/cli/utils/shared.tspackages/paykit/src/core/__tests__/validate-options.test.tspackages/paykit/src/core/context.tspackages/paykit/src/core/validate-options.tspackages/paykit/src/database/migrations/0002_add-product-price-currency.sqlpackages/paykit/src/database/migrations/meta/0002_snapshot.jsonpackages/paykit/src/database/migrations/meta/_journal.jsonpackages/paykit/src/database/schema.tspackages/paykit/src/index.tspackages/paykit/src/product/__tests__/product-sync.service.test.tspackages/paykit/src/product/product-sync.service.tspackages/paykit/src/product/product.service.tspackages/paykit/src/providers/provider.tspackages/paykit/src/stripe/__tests__/stripe-provider.test.tspackages/paykit/src/stripe/currency.tspackages/paykit/src/stripe/stripe-provider.tspackages/paykit/src/subscription/subscription.service.tspackages/paykit/src/subscription/subscription.types.tspackages/paykit/src/types/__tests__/schema.test.tspackages/paykit/src/types/schema.ts
There was a problem hiding this comment.
3 issues found across 23 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/paykit/src/types/schema.ts">
<violation number="1" location="packages/paykit/src/types/schema.ts:329">
P0: Hash format changed without hash backfill for existing products. Existing plan lookups by `id+hash` can fail and surface `PLAN_NOT_FOUND`/skip default-plan attachment after upgrade.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
| group: plan.group, | ||
| isDefault: plan.isDefault, | ||
| priceAmount: plan.priceAmount, | ||
| priceCurrency: plan.priceCurrency, |
There was a problem hiding this comment.
P0: Hash format changed without hash backfill for existing products. Existing plan lookups by id+hash can fail and surface PLAN_NOT_FOUND/skip default-plan attachment after upgrade.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/paykit/src/types/schema.ts, line 329:
<comment>Hash format changed without hash backfill for existing products. Existing plan lookups by `id+hash` can fail and surface `PLAN_NOT_FOUND`/skip default-plan attachment after upgrade.</comment>
<file context>
@@ -325,6 +326,7 @@ export function computePlanHash(plan: Omit<NormalizedPlan, "hash">): string {
group: plan.group,
isDefault: plan.isDefault,
priceAmount: plan.priceAmount,
+ priceCurrency: plan.priceCurrency,
priceInterval: plan.priceInterval,
features: plan.includes.map((f) => ({
</file context>
There was a problem hiding this comment.
1 issue found across 7 files (changes from recent commits).
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="packages/paykit/src/product/product.service.ts">
<violation number="1" location="packages/paykit/src/product/product.service.ts:116">
P2: Plan/feature equivalence logic is duplicated across product services. Future edits can make sync and lookup disagree on change detection.</violation>
</file>
Reply with feedback, questions, or to request a fix.
Fix all with cubic | Re-trigger cubic
| return JSON.stringify(config ?? null); | ||
| } | ||
|
|
||
| function productFeaturesMatch( |
There was a problem hiding this comment.
P2: Plan/feature equivalence logic is duplicated across product services. Future edits can make sync and lookup disagree on change detection.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/paykit/src/product/product.service.ts, line 116:
<comment>Plan/feature equivalence logic is duplicated across product services. Future edits can make sync and lookup disagree on change detection.</comment>
<file context>
@@ -109,6 +109,62 @@ export async function getProductByHash(
+ return JSON.stringify(config ?? null);
+}
+
+function productFeaturesMatch(
+ existing: readonly StoredProductFeature[],
+ next: readonly NormalizedPlanFeature[],
</file context>
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@packages/paykit/src/product/product.service.ts`:
- Around line 112-113: The serializeFeatureConfig function uses JSON.stringify
which produces different strings for semantically identical configs if their
object keys are in different order, causing getProductByPlan to fail to match
compatible stored products. Replace the simple JSON.stringify approach with a
stable serialization method that sorts object keys consistently before
stringification, or implement a deep-equality helper for comparing configs.
Apply the same fix to the other locations where this pattern appears (at line
131-131 in this file and at product-sync.service.ts:43).
In `@packages/paykit/src/subscription/subscription.service.ts`:
- Around line 248-252: The issue is that the `isSamePlan` predicate only
compares plan IDs and doesn't account for currency or provider changes, causing
legitimate same-plan currency changes to be incorrectly rejected. When
`cancelExistingProviderSubscriptionForCheckout` checks `isSamePlan`, it returns
true for a USD→EUR change on the same plan, leading to rejection before the
legitimate `isPaidCurrencyChange` check is evaluated. Fix this by either
reordering the guard condition to evaluate `isPaidCurrencyChange` before
`isSamePlan`, or by refining the `isSamePlan` predicate itself to also
distinguish between currency and provider differences beyond just comparing plan
IDs.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Repository UI
Review profile: CHILL
Plan: Pro
Run ID: bc6f707a-6d39-4576-a941-aa76f163a2f4
📒 Files selected for processing (7)
packages/paykit/src/core/__tests__/validate-options.test.tspackages/paykit/src/core/validate-options.tspackages/paykit/src/customer/customer.service.tspackages/paykit/src/product/__tests__/product.service.test.tspackages/paykit/src/product/product.service.tspackages/paykit/src/subscription/subscription.service.tspackages/paykit/src/types/schema.ts
✅ Files skipped from review due to trivial changes (1)
- packages/paykit/src/product/tests/product.service.test.ts
🚧 Files skipped from review as they are similar to previous changes (3)
- packages/paykit/src/core/tests/validate-options.test.ts
- packages/paykit/src/types/schema.ts
- packages/paykit/src/core/validate-options.ts
| function serializeFeatureConfig(config: Record<string, unknown> | null): string { | ||
| return JSON.stringify(config ?? null); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Demonstrates why JSON.stringify is not semantic equality for object configs.
node - <<'NODE'
const stored = { quota: 10, mode: "soft" };
const normalized = { mode: "soft", quota: 10 };
console.log(JSON.stringify(stored));
console.log(JSON.stringify(normalized));
console.log(JSON.stringify(stored) === JSON.stringify(normalized));
NODERepository: getpaykit/paykit
Length of output: 214
🏁 Script executed:
cat -n packages/paykit/src/product/product.service.ts | head -150Repository: getpaykit/paykit
Length of output: 5325
🏁 Script executed:
cat -n packages/paykit/src/product/product.service.ts | sed -n '150,200p'Repository: getpaykit/paykit
Length of output: 1814
🏁 Script executed:
rg "config" packages/paykit/src/product/product.service.ts -B 2 -A 2 | head -80Repository: getpaykit/paykit
Length of output: 704
🏁 Script executed:
find packages/paykit/src/types -type f -name "*.ts" | head -20Repository: getpaykit/paykit
Length of output: 461
🏁 Script executed:
cat -n packages/paykit/src/types/schema.ts | head -100Repository: getpaykit/paykit
Length of output: 4094
🏁 Script executed:
cat -n packages/paykit/src/types/schema.ts | sed -n '100,200p'Repository: getpaykit/paykit
Length of output: 3522
🏁 Script executed:
rg "MeteredFeatureConfig|config:" packages/paykit/src -B 2 -A 2 | head -120Repository: getpaykit/paykit
Length of output: 7332
🏁 Script executed:
cat -n packages/paykit/src/product/product-sync.service.ts | grep -A 20 "serializeFeatureConfig"Repository: getpaykit/paykit
Length of output: 1677
🏁 Script executed:
rg "NormalizedPlanFeature|StoredProductFeature" packages/paykit/src/types/models.ts -B 2 -A 5Repository: getpaykit/paykit
Length of output: 463
🏁 Script executed:
rg "JSON.parse|jsonb" packages/paykit/src -B 2 -A 2 | head -80Repository: getpaykit/paykit
Length of output: 6297
🏁 Script executed:
cat -n packages/paykit/src/types/schema.ts | sed -n '220,280p'Repository: getpaykit/paykit
Length of output: 2417
🏁 Script executed:
rg "productSnapshotMatchesPlan|getProductByPlan" packages/paykit/src -B 3 -A 3 | head -60Repository: getpaykit/paykit
Length of output: 4913
🏁 Script executed:
cat -n packages/paykit/src/product/__tests__/product.service.test.ts | head -150Repository: getpaykit/paykit
Length of output: 2936
🏁 Script executed:
rg "config.*limit.*reset|reset.*limit" packages/paykit/src --type ts -B 2 -A 2 | head -80Repository: getpaykit/paykit
Length of output: 1362
🏁 Script executed:
cat -n packages/paykit/src/types/schema.ts | sed -n '27,30p'Repository: getpaykit/paykit
Length of output: 363
🏁 Script executed:
rg "config.*:" packages/paykit/src/types/schema.ts -B 5 -A 5 | grep -A 15 "meteredFeatureConfigSchema\|parsedConfig.data"Repository: getpaykit/paykit
Length of output: 649
Use stable config comparison for semantic fallback.
JSON.stringify makes feature config equality depend on object key order, so getProductByPlan can miss an otherwise compatible stored product when JSON/config key order changes. Use a stable serializer or deep-equality helper before comparing configs.
Suggested localized fix
+function stableSerialize(value: unknown): string {
+ if (value === null || typeof value !== "object") {
+ return JSON.stringify(value) ?? "null";
+ }
+
+ if (Array.isArray(value)) {
+ return `[${value.map(stableSerialize).join(",")}]`;
+ }
+
+ return `{${Object.entries(value as Record<string, unknown>)
+ .filter(([, entryValue]) => entryValue !== undefined)
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableSerialize(entryValue)}`)
+ .join(",")}}`;
+}
+
function serializeFeatureConfig(config: Record<string, unknown> | null): string {
- return JSON.stringify(config ?? null);
+ return stableSerialize(config ?? null);
}Also applies to: 131-131, product-sync.service.ts:43
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/paykit/src/product/product.service.ts` around lines 112 - 113, The
serializeFeatureConfig function uses JSON.stringify which produces different
strings for semantically identical configs if their object keys are in different
order, causing getProductByPlan to fail to match compatible stored products.
Replace the simple JSON.stringify approach with a stable serialization method
that sorts object keys consistently before stringification, or implement a
deep-equality helper for comparing configs. Apply the same fix to the other
locations where this pattern appears (at line 131-131 in this file and at
product-sync.service.ts:43).
| if (!completion.subCtx.isUpgrade && !completion.subCtx.isPaidCurrencyChange) { | ||
| throw PayKitError.from( | ||
| "BAD_REQUEST", | ||
| PAYKIT_ERROR_CODES.PROVIDER_WEBHOOK_INVALID, | ||
| `Checkout completion is only valid for new paid subscriptions or upgrades to "${completion.subCtx.storedPlan.id}"`, | ||
| `Checkout completion is only valid for new paid subscriptions, upgrades, or cross-currency changes to "${completion.subCtx.storedPlan.id}"`, |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Inspect the local same-plan predicate and checkout completion ordering.
# Expectation: isSamePlan should compare stored product/version/provider identity, or the checkout
# completion flow should handle isPaidCurrencyChange before same-plan rejection.
rg -n -C8 '\bisSamePlan\b|isPaidCurrencyChange|cancelExistingProviderSubscriptionForCheckout' packages/paykit/src/subscription/subscription.service.tsRepository: getpaykit/paykit
Length of output: 6260
Refactor isSamePlan check ordering or definition to handle same-plan currency changes.
The isSamePlan predicate (line 384-386) compares only plan IDs: activeSubscription?.planId === storedPlan.id. This means a same-plan currency change (USD→EUR for plan "X") would incorrectly be treated as a same-plan operation.
In cancelExistingProviderSubscriptionForCheckout, if a checkout-backed subscription is created for a same-plan currency change with a different provider subscription ID, the check at line 232 would return true, leading to rejection at line 237-241 before the legitimate isPaidCurrencyChange exception at line 248. This blocks valid currency changes and violates the stated intent in the error message.
Fix: Either (1) move the isPaidCurrencyChange check before the isSamePlan check in the guard, or (2) refine isSamePlan to also distinguish currency/provider details.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@packages/paykit/src/subscription/subscription.service.ts` around lines 248 -
252, The issue is that the `isSamePlan` predicate only compares plan IDs and
doesn't account for currency or provider changes, causing legitimate same-plan
currency changes to be incorrectly rejected. When
`cancelExistingProviderSubscriptionForCheckout` checks `isSamePlan`, it returns
true for a USD→EUR change on the same plan, leading to rejection before the
legitimate `isPaidCurrencyChange` check is evaluated. Fix this by either
reordering the guard condition to evaluate `isPaidCurrencyChange` before
`isSamePlan`, or by refining the `isSamePlan` predicate itself to also
distinguish between currency and provider differences beyond just comparing plan
IDs.
Summary
usdandeurusdTests
No release/changset in this PR; release will happen separately.
Summary by cubic
Adds global Stripe currency support for
usdandeur. Products now storepriceCurrency, plan hashes include currency, syncing/subscriptions handle currency changes, and the CLI formats EUR prices.New Features
stripe.currencyoption (usdoreur, defaultusd) with strict lowercase 3-letter validation.priceCurrencyon products and backfill existing paid plans tousd.formatPriceto localize currency and support EUR.Migration
0002_add-product-price-currency.paykitjs push) to create currency-specific Stripe Prices (setstripe.currencytoeurif needed, then push).Written for commit 310f16e. Summary will update on new commits.
Summary by CodeRabbit
Release Notes
price_currencyto products and initialized existing records.