Skip to content

feat(paykit): add Stripe currency support#199

Merged
maxktz merged 2 commits into
mainfrom
feat/currency-change
Jun 23, 2026
Merged

feat(paykit): add Stripe currency support#199
maxktz merged 2 commits into
mainfrom
feat/currency-change

Conversation

@maxktz

@maxktz maxktz commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

Summary

  • add global Stripe currency support for usd and eur
  • persist product price currency and backfill existing paid products to usd
  • create a new Stripe Price when amount, interval, or currency changes
  • add focused unit coverage and EUR product-sync E2E coverage

Tests

  • pnpm --filter paykitjs typecheck
  • pnpm test:unit
  • pnpm --filter paykitjs build
  • pnpm format:check
  • pnpm lint
  • PAYKIT_ALLOW_UNSIGNED_PAYLOADS=1 E2E_STRIPE_WHSEC=whsec_dummy pnpm --filter e2e exec vitest run --project=cli cli/push.test.ts
  • full Stripe E2E earlier: 19 files / 21 tests passed

No release/changset in this PR; release will happen separately.


Summary by cubic

Adds global Stripe currency support for usd and eur. Products now store priceCurrency, plan hashes include currency, syncing/subscriptions handle currency changes, and the CLI formats EUR prices.

  • New Features

    • Add stripe.currency option (usd or eur, default usd) with strict lowercase 3-letter validation.
    • Normalize paid plans with the configured currency and include it in plan hashes; persist priceCurrency on products and backfill existing paid plans to usd.
    • Create a new Stripe Price when amount, interval, or currency changes; provider uses the plan’s currency for price creation and the configured currency for invoices.
    • Handle cross-currency paid plan changes by creating a new checkout-backed subscription.
    • Improve CLI formatPrice to localize currency and support EUR.
    • Resolve stored products by semantic plan match when only the hash changed to avoid unnecessary churn.
  • Migration

    • Run DB migration 0002_add-product-price-currency.
    • Re-run product sync (e.g., paykitjs push) to create currency-specific Stripe Prices (set stripe.currency to eur if needed, then push).

Written for commit 310f16e. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

Release Notes

  • New Features
    • Added multi-currency product pricing with support for USD and EUR.
    • Plans now carry a currency setting where applicable, affecting hashing and product matching.
    • Switching between paid plans of different currencies now uses checkout flow.
  • Improvements
    • Price display uses locale-aware currency formatting.
    • Product sync now tracks and syncs the plan’s currency.
  • Validation
    • Stripe currency input is now strictly validated (lowercase 3-letter, supported set).
  • Database
    • Added price_currency to products and initialized existing records.
  • Tests
    • Expanded formatting, schema, and end-to-end sync coverage (including EUR).

@vercel

vercel Bot commented Jun 23, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
paykit Skipped Skipped Jun 23, 2026 12:57pm

@coderabbitai

coderabbitai Bot commented Jun 23, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds configurable multi-currency support (USD/EUR) to PayKit. Introduces a stripe/currency.ts module with supported currency constants and helpers, propagates priceCurrency through schema normalization, database schema, product sync service, Stripe provider, and subscription routing. CLI formatting switches to Intl.NumberFormat. A new database migration adds the price_currency column. Product lookup is generalized to getProductByPlan for semantic plan matching beyond hash-based retrieval.

Changes

Multi-Currency (EUR) Support

Layer / File(s) Summary
Stripe currency module and public contracts
packages/paykit/src/stripe/currency.ts, packages/paykit/src/index.ts, packages/paykit/src/types/schema.ts, packages/paykit/src/subscription/subscription.types.ts
New currency.ts exports DEFAULT_STRIPE_CURRENCY, SUPPORTED_STRIPE_CURRENCIES, StripeCurrency, getStripeCurrency, and isSupportedStripeCurrency. StripeCurrency is re-exported publicly. NormalizedPlan gains priceCurrency; computePlanHash includes it; normalizeSchema accepts optional priceCurrency input. SubscriptionWithCatalog.priceCurrency typed as string | null.
Database schema and migration
packages/paykit/src/database/schema.ts, packages/paykit/src/database/migrations/0002_add-product-price-currency.sql, packages/paykit/src/database/migrations/meta/*
Adds priceCurrency column to the Drizzle product table schema. Migration 0002_add-product-price-currency.sql creates price_currency and backfills 'usd' for existing paid rows. Journal and full snapshot updated.
Options validation and context wiring
packages/paykit/src/core/validate-options.ts, packages/paykit/src/core/context.ts, packages/paykit/src/core/__tests__/validate-options.test.ts
assertValidPayKitOptions gains a local assertValidStripeCurrency helper rejecting non-lowercase/non-3-letter or unsupported currency codes. createContext passes priceCurrency: getStripeCurrency(options.stripe) into normalizeSchema. Test cases cover acceptance of usd/eur and rejection of gbp, EUR, and empty strings with appropriate error messages.
Product service with plan-based lookup
packages/paykit/src/product/product.service.ts, packages/paykit/src/product/__tests__/product.service.test.ts
Introduces getProductByPlan function that performs exact hash-based lookup first, then validates the latest snapshot against the normalized plan via semantic comparison (including priceCurrency, price, group, default status, and feature list). Extends insertProductVersion to accept and persist priceCurrency. Test suite validates hash-based and fallback-on-mismatch behavior.
Product sync service with currency-aware changes
packages/paykit/src/product/product-sync.service.ts, packages/paykit/src/product/__tests__/product-sync.service.test.ts
planChanged now includes priceCurrency comparison. New priceChanged helper isolates price-only changes; withoutExistingPrice strips existing price IDs for re-sync. Eligibility for paid plan sync requires non-null priceCurrency. Payload forwarded to PaymentProvider.syncProducts includes priceCurrency and uses conditional providerProductForSync when only price changes. Test cases verify dry-run reports creation for USD↔EUR and sync creates new version with EUR pricing.
Payment provider contract update
packages/paykit/src/providers/provider.ts
PaymentProvider.syncProducts method signature updated to include priceCurrency: string in the data.products array element type.
Stripe provider currency handling
packages/paykit/src/stripe/stripe-provider.ts, packages/paykit/src/stripe/__tests__/stripe-provider.test.ts
Invoice currency resolves via getStripeCurrency(options) instead of a hardcoded constant. Price creation uses each product's priceCurrency. createStripeAdapter applies DEFAULT_STRIPE_CURRENCY fallback in optionsWithDefaults. Test suite validates price creation uses product currency and invoice creation uses provider currency.
Subscription cross-currency routing
packages/paykit/src/subscription/subscription.service.ts
loadSubscribeContext computes isPaidCurrencyChange and excludes it from the upgrade path; uses getProductByPlan for product resolution when productInternalId is absent. subscribeToPlan routes paid cross-currency changes to createCheckoutSubscribe. mapJoinRowToSubscriptionWithCatalog maps priceCurrency. Checkout completion handler permits both upgrades and cross-currency changes.
Customer default plan provisioning
packages/paykit/src/customer/customer.service.ts
ensureDefaultPlansForCustomer now uses getProductByPlan instead of getProductByHash for resolving stored products.
CLI price formatting with currency
packages/paykit/src/cli/utils/format.ts, packages/paykit/src/cli/utils/shared.ts, packages/paykit/src/cli/__tests__/format.test.ts
formatPrice gains optional currency parameter and uses Intl.NumberFormat with style: "currency" instead of manual $ string interpolation. formatProductDiffs passes plan.priceCurrency ?? "usd" to formatPrice. Test suite validates EUR formatting for integer (€29/mo) and decimal (€29.99/mo) amounts.
Schema normalization and E2E tests
packages/paykit/src/types/__tests__/schema.test.ts, e2e/cli/push.test.ts
normalizeSchema test validates that paid plans store configured currency, hashes change across currencies, and free plans remain currencyless. Extended e2e push test asserts priceCurrency in database (free: null, pro: "usd"). New EUR e2e test verifies full sync flow: CLI context with EUR, product sync, database validation, Stripe Price currency verification, and dry-run reporting no changes.

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
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • getpaykit/paykit#116: Touches the same formatProductDiffs / formatPrice call site in cli/utils/shared.ts that this PR now extends with a currency argument.
  • getpaykit/paykit#119: Modifies the same syncProducts integration in product/product-sync.service.ts and the provider wiring that this PR extends to carry priceCurrency.
  • getpaykit/paykit#139: Refactors PaymentProvider.syncProducts batching and providers/provider.ts product input shape, the same contract this PR extends with priceCurrency.

Poem

🐇 Hoppin' through currencies with glee,
EUR and USD, now both run free!
A migration here, a hash update there,
Intl.NumberFormat formats with flair.
No hardcoded dollars—the rabbit says: done,
Multi-currency support has finally begun! 🌍

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 5.41% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(paykit): add Stripe currency support' accurately and concisely describes the main feature addition—multi-currency support for Stripe (USD and EUR)—which is the core change across all modified files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/currency-change

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/paykit/src/types/schema.ts (1)

432-432: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use DEFAULT_STRIPE_CURRENCY instead 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

📥 Commits

Reviewing files that changed from the base of the PR and between f6cec54 and bce0ffa.

📒 Files selected for processing (23)
  • e2e/cli/push.test.ts
  • packages/paykit/src/cli/__tests__/format.test.ts
  • packages/paykit/src/cli/utils/format.ts
  • packages/paykit/src/cli/utils/shared.ts
  • packages/paykit/src/core/__tests__/validate-options.test.ts
  • packages/paykit/src/core/context.ts
  • packages/paykit/src/core/validate-options.ts
  • packages/paykit/src/database/migrations/0002_add-product-price-currency.sql
  • packages/paykit/src/database/migrations/meta/0002_snapshot.json
  • packages/paykit/src/database/migrations/meta/_journal.json
  • packages/paykit/src/database/schema.ts
  • packages/paykit/src/index.ts
  • packages/paykit/src/product/__tests__/product-sync.service.test.ts
  • packages/paykit/src/product/product-sync.service.ts
  • packages/paykit/src/product/product.service.ts
  • packages/paykit/src/providers/provider.ts
  • packages/paykit/src/stripe/__tests__/stripe-provider.test.ts
  • packages/paykit/src/stripe/currency.ts
  • packages/paykit/src/stripe/stripe-provider.ts
  • packages/paykit/src/subscription/subscription.service.ts
  • packages/paykit/src/subscription/subscription.types.ts
  • packages/paykit/src/types/__tests__/schema.test.ts
  • packages/paykit/src/types/schema.ts

Comment thread packages/paykit/src/core/validate-options.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

@cubic-dev-ai cubic-dev-ai Bot Jun 23, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with cubic

Comment thread packages/paykit/src/subscription/subscription.service.ts
Comment thread packages/paykit/src/core/validate-options.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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(

@cubic-dev-ai cubic-dev-ai Bot Jun 23, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
Fix with cubic

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

📥 Commits

Reviewing files that changed from the base of the PR and between bce0ffa and 310f16e.

📒 Files selected for processing (7)
  • packages/paykit/src/core/__tests__/validate-options.test.ts
  • packages/paykit/src/core/validate-options.ts
  • packages/paykit/src/customer/customer.service.ts
  • packages/paykit/src/product/__tests__/product.service.test.ts
  • packages/paykit/src/product/product.service.ts
  • packages/paykit/src/subscription/subscription.service.ts
  • packages/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

Comment on lines +112 to +113
function serializeFeatureConfig(config: Record<string, unknown> | null): string {
return JSON.stringify(config ?? null);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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));
NODE

Repository: getpaykit/paykit

Length of output: 214


🏁 Script executed:

cat -n packages/paykit/src/product/product.service.ts | head -150

Repository: 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 -80

Repository: getpaykit/paykit

Length of output: 704


🏁 Script executed:

find packages/paykit/src/types -type f -name "*.ts" | head -20

Repository: getpaykit/paykit

Length of output: 461


🏁 Script executed:

cat -n packages/paykit/src/types/schema.ts | head -100

Repository: 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 -120

Repository: 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 5

Repository: getpaykit/paykit

Length of output: 463


🏁 Script executed:

rg "JSON.parse|jsonb" packages/paykit/src -B 2 -A 2 | head -80

Repository: 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 -60

Repository: getpaykit/paykit

Length of output: 4913


🏁 Script executed:

cat -n packages/paykit/src/product/__tests__/product.service.test.ts | head -150

Repository: getpaykit/paykit

Length of output: 2936


🏁 Script executed:

rg "config.*limit.*reset|reset.*limit" packages/paykit/src --type ts -B 2 -A 2 | head -80

Repository: 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).

Comment on lines +248 to +252
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}"`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 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.ts

Repository: 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.

@maxktz maxktz merged commit 7b66ed4 into main Jun 23, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant