Skip to content

feat(paykit): resolve products by hash instead of latest version#124

Merged
maxktz merged 4 commits intomainfrom
maxktz/feat/release003
Apr 13, 2026
Merged

feat(paykit): resolve products by hash instead of latest version#124
maxktz merged 4 commits intomainfrom
maxktz/feat/release003

Conversation

@maxktz
Copy link
Copy Markdown
Contributor

@maxktz maxktz commented Apr 13, 2026

Summary

  • Subscribe and default-plan flows now look up the product version matching the current schema hash instead of always fetching the latest version
  • Eliminates the failure window between push and deploy where a hash mismatch would reject subscriptions
  • Adds docs on plan versioning and recommended production push usage

Test plan

  • Typecheck passes
  • Customer service tests pass
  • Verify subscribe flow works when pushed version != latest (e.g. push v2, old code still expects v1)
  • Verify subscribe throws when plan was never pushed

Closes #103

Summary by CodeRabbit

  • Documentation

    • Clarified CLI plan-versioning behavior and added production deploy guidance and CLI reference.
    • Improved docs on product/plan sync and push-before-build workflow.
  • Bug Fixes

    • More reliable plan/version resolution during subscription and checkout flows so subscribers receive the correct plan version.
    • Shortened error messages and more robust metadata comparison for provider syncs.

maxktz added 2 commits April 13, 2026 12:10
PostgreSQL JSONB does not preserve key insertion order, so
JSON.stringify on a value read back from the DB can differ from the
original input. Sort keys before serialising to avoid false-positive
provider syncs.
Subscribe and default-plan flows now look up the product version matching
the current schema hash rather than always fetching the latest version.
This eliminates the failure window between push and deploy where a hash
mismatch would reject subscriptions.

Adds docs on plan versioning and production push usage.

Closes #103
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 13, 2026

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
paykit Skipped Skipped Apr 13, 2026 10:09am

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 13, 2026

📝 Walkthrough
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.33% 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 PR title accurately captures the main architectural change: shifting product resolution from latest-version lookup to hash-based lookup to prevent downtime during deployments.
Linked Issues check ✅ Passed The PR directly addresses issue #103 by eliminating the failure window between push and deploy through hash-based product resolution in subscribe and webhook flows, preventing downtime.
Out of Scope Changes check ✅ Passed All changes align with the stated objective: error message simplifications, documentation updates, and new product lookup helpers support the core hash-based resolution mechanism.

✏️ 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 maxktz/feat/release003

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

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
packages/paykit/src/customer/customer.service.ts (1)

162-164: Warn when a default plan is skipped.

Lines 163-164 silently continue when the current default plan was never pushed for this hash. That leaves new customers without their expected default entitlements and gives operators no signal. A warning with { customerId, planId, hash } would make deploy drift much easier to diagnose.

Possible improvement
     const storedPlan = await getProductByHash(ctx.database, defaultPlan.id, defaultPlan.hash);
     if (!storedPlan) {
+      ctx.logger.warn(
+        { customerId, planId: defaultPlan.id, hash: defaultPlan.hash },
+        "skipping default plan: no synced version matches the current schema",
+      );
       continue;
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/paykit/src/customer/customer.service.ts` around lines 162 - 164,
When getProductByHash returns no storedPlan, add a warning log before continuing
so operators can see which default plan was skipped; specifically, in the block
after const storedPlan = await getProductByHash(ctx.database, defaultPlan.id,
defaultPlan.hash); if (!storedPlan) { ... } emit a warning via the service's
existing logger (e.g., ctx.logger.warn or similar) including { customerId:
<current customer id variable>, planId: defaultPlan.id, hash: defaultPlan.hash }
and then continue; keep the existing continue behavior but ensure the structured
warning is logged to aid debugging.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/paykit/src/product/product.service.ts`:
- Around line 93-100: getProductByHash currently uses
database.query.product.findFirst with where: and(eq(product.id, id),
eq(product.hash, hash)) which can return an arbitrary row when multiple (id,
hash) duplicates exist; change the query in getProductByHash to
deterministically return the newest version by adding an orderBy on
product.version descending (or fetch all matches and select the max version) so
the returned StoredProduct is always the latest version for that id+hash combo;
update the call site in getProductByHash (referencing PayKitDatabase,
database.query.product.findFirst, and product.version) to enforce this
deterministic selection.

In `@packages/paykit/src/subscription/subscription.service.ts`:
- Around line 76-85: The webhook currently re-reads the current plan hash during
checkout.completed which causes mismatches when plans change; modify
prepareSubscribeCheckoutCompleted and loadSubscribeContext so that when
initiating checkout you persist normalizedPlan.hash into checkout metadata
(paykit_plan_id and a new paykit_plan_hash metadata key) and then, in
prepareSubscribeCheckoutCompleted, read and use that persisted hash instead of
calling normalizedPlan.hash again; update any call sites that only stored
paykit_plan_id to include the plan hash and ensure
getProductByHash(ctx.database, input.planId, ...) is invoked with the stored
metadata hash to resolve the exact version used at checkout.

---

Nitpick comments:
In `@packages/paykit/src/customer/customer.service.ts`:
- Around line 162-164: When getProductByHash returns no storedPlan, add a
warning log before continuing so operators can see which default plan was
skipped; specifically, in the block after const storedPlan = await
getProductByHash(ctx.database, defaultPlan.id, defaultPlan.hash); if
(!storedPlan) { ... } emit a warning via the service's existing logger (e.g.,
ctx.logger.warn or similar) including { customerId: <current customer id
variable>, planId: defaultPlan.id, hash: defaultPlan.hash } and then continue;
keep the existing continue behavior but ensure the structured warning is logged
to aid debugging.
🪄 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: 3b7f7a71-cee3-45f0-acc9-b084965e69f2

📥 Commits

Reviewing files that changed from the base of the PR and between e3c1685 and 0165229.

📒 Files selected for processing (7)
  • landing/content/docs/concepts/cli.mdx
  • landing/content/docs/concepts/database.mdx
  • landing/content/docs/get-started/installation.mdx
  • packages/paykit/src/core/errors.ts
  • packages/paykit/src/customer/customer.service.ts
  • packages/paykit/src/product/product.service.ts
  • packages/paykit/src/subscription/subscription.service.ts

…ution

When a checkout is created, the plan hash is now stored in Stripe metadata
(paykit_plan_hash). On checkout.completed webhook, the persisted hash is
used to resolve the exact product version from checkout time, preventing
mismatches if the plan changed between checkout and webhook.

Also adds resolveStoredPlanFeatures fallback so the webhook path can load
features from the database when the plan is no longer in the code schema.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/paykit/src/subscription/subscription.service.ts`:
- Around line 161-164: The code currently prefers normalizedPlan.includes
unconditionally which can cause entitlements to be built from a mismatched plan
version; update the logic that sets planFeatures so it uses
normalizedPlan.includes only when the normalizedPlan's hash/version matches the
resolved/stored plan (compare whatever identifier exists on normalizedPlan,
e.g., normalizedPlan.hash or normalizedPlan.planHash, against
storedPlan/internalId or input.planHash), otherwise call await
resolveStoredPlanFeatures(ctx.database, storedPlan.internalId); change the
ternary/conditional setting of planFeatures to include this equality check so
downstream entitlement creation (lines referencing planFeatures) always uses the
correct plan version.
🪄 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: 52023d54-a7e2-4d19-95bd-c449a89fcd52

📥 Commits

Reviewing files that changed from the base of the PR and between 0165229 and b0468b2.

📒 Files selected for processing (3)
  • packages/paykit/src/product/product.service.ts
  • packages/paykit/src/subscription/subscription.service.ts
  • packages/paykit/src/subscription/subscription.types.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/paykit/src/subscription/subscription.types.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/paykit/src/product/product.service.ts

Comment on lines +161 to +164
const planFeatures = normalizedPlan
? normalizedPlan.includes
: await resolveStoredPlanFeatures(ctx.database, storedPlan.internalId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Critical: feature source can drift from resolved plan version.

When input.planHash resolves an older/newer stored plan, normalizedPlan may still exist for a different hash. Line 161 currently prefers normalizedPlan.includes unconditionally, so entitlements can be created from the wrong version (downstream at Line 1085 and Line 1130).

💡 Suggested fix
-  const planFeatures = normalizedPlan
-    ? normalizedPlan.includes
-    : await resolveStoredPlanFeatures(ctx.database, storedPlan.internalId);
+  const planFeatures =
+    normalizedPlan && normalizedPlan.hash === storedPlan.hash
+      ? normalizedPlan.includes
+      : await resolveStoredPlanFeatures(ctx.database, storedPlan.internalId);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/paykit/src/subscription/subscription.service.ts` around lines 161 -
164, The code currently prefers normalizedPlan.includes unconditionally which
can cause entitlements to be built from a mismatched plan version; update the
logic that sets planFeatures so it uses normalizedPlan.includes only when the
normalizedPlan's hash/version matches the resolved/stored plan (compare whatever
identifier exists on normalizedPlan, e.g., normalizedPlan.hash or
normalizedPlan.planHash, against storedPlan/internalId or input.planHash),
otherwise call await resolveStoredPlanFeatures(ctx.database,
storedPlan.internalId); change the ternary/conditional setting of planFeatures
to include this equality check so downstream entitlement creation (lines
referencing planFeatures) always uses the correct plan version.

…metadata

Simpler and more direct - stores the product's primary key in Stripe
checkout metadata instead of the hash. Webhook resolution is now a
single PK lookup instead of a compound query.
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/paykit/src/subscription/subscription.service.ts (1)

312-333: ⚠️ Potential issue | 🔴 Critical

Do not silently fall back when paykit_product_internal_id is missing.

Passing undefined here sends loadSubscribeContext() back to current-hash resolution, so any checkout.completed event missing the new metadata still resolves against the current schema instead of the version that created the checkout. That reintroduces the mismatch window this PR is trying to close. Safer options are to reject the webhook when paykit_product_internal_id is absent, or use checkoutSubscription.providerPriceId as the backward-compatible fallback instead of normalizedPlan.hash.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/paykit/src/subscription/subscription.service.ts` around lines 312 -
333, The code silently falls back to undefined for paykit_product_internal_id
which causes loadSubscribeContext to resolve against the current schema;
instead, validate and fail fast or provide a safe fallback: when
productInternalId is absent, either throw a BAD_REQUEST (reject the webhook) or
pass checkoutSubscription.providerPriceId into loadSubscribeContext so it uses
the provider price identifier for version resolution (do not use
normalizedPlan.hash as the fallback); update the call sites using
productInternalId and the loadSubscribeContext invocation accordingly
(referencing paykit_product_internal_id, loadSubscribeContext,
checkoutSubscription, checkoutSubscription.providerPriceId, and
normalizedPlan.hash).
♻️ Duplicate comments (1)
packages/paykit/src/subscription/subscription.service.ts (1)

156-158: ⚠️ Potential issue | 🔴 Critical

Use normalized features only when the resolved version matches.

productInternalId can intentionally resolve an older/newer stored product, but this still prefers normalizedPlan.includes whenever the plan exists in the current schema. That means Line 1080 and Line 1125 can still create entitlements from the wrong version.

Suggested fix
-  const planFeatures = normalizedPlan
-    ? normalizedPlan.includes
-    : await resolveStoredPlanFeatures(ctx.database, storedPlan.internalId);
+  const planFeatures =
+    normalizedPlan && normalizedPlan.hash === storedPlan.hash
+      ? normalizedPlan.includes
+      : await resolveStoredPlanFeatures(ctx.database, storedPlan.internalId);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/paykit/src/subscription/subscription.service.ts` around lines 156 -
158, The current logic uses normalizedPlan.includes whenever normalizedPlan
exists even if it refers to a different stored plan version; update the
selection so planFeatures uses normalizedPlan.includes only when the
normalizedPlan actually corresponds to the resolved stored plan (e.g., compare
normalizedPlan.internalId or version to storedPlan.internalId/version),
otherwise call await resolveStoredPlanFeatures(ctx.database,
storedPlan.internalId); update any downstream entitlement creation sites
(referenced at the previous review as lines creating entitlements) to use this
corrected planFeatures value so entitlements are created from the matched plan
version.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/paykit/src/subscription/subscription.service.ts`:
- Around line 100-105: The code resolves matchingProduct from
input.productInternalId without verifying it belongs to the requested planId,
which can cause mismatched product vs plan usage in loadSubscribeContext; update
the branch that fetches by getProductByInternalId to validate that the returned
matchingProduct.id (or matchingProduct.internalPlanId equivalent) equals
input.planId (or the normalizedPlan.id/hash as appropriate) and if it does not,
treat it as a mismatch—either set matchingProduct to null and surface a clear
validation error or throw a BadRequest/ValidationError; ensure this check occurs
before calling withProviderInfo so providerId resolution uses a verified
product.

---

Outside diff comments:
In `@packages/paykit/src/subscription/subscription.service.ts`:
- Around line 312-333: The code silently falls back to undefined for
paykit_product_internal_id which causes loadSubscribeContext to resolve against
the current schema; instead, validate and fail fast or provide a safe fallback:
when productInternalId is absent, either throw a BAD_REQUEST (reject the
webhook) or pass checkoutSubscription.providerPriceId into loadSubscribeContext
so it uses the provider price identifier for version resolution (do not use
normalizedPlan.hash as the fallback); update the call sites using
productInternalId and the loadSubscribeContext invocation accordingly
(referencing paykit_product_internal_id, loadSubscribeContext,
checkoutSubscription, checkoutSubscription.providerPriceId, and
normalizedPlan.hash).

---

Duplicate comments:
In `@packages/paykit/src/subscription/subscription.service.ts`:
- Around line 156-158: The current logic uses normalizedPlan.includes whenever
normalizedPlan exists even if it refers to a different stored plan version;
update the selection so planFeatures uses normalizedPlan.includes only when the
normalizedPlan actually corresponds to the resolved stored plan (e.g., compare
normalizedPlan.internalId or version to storedPlan.internalId/version),
otherwise call await resolveStoredPlanFeatures(ctx.database,
storedPlan.internalId); update any downstream entitlement creation sites
(referenced at the previous review as lines creating entitlements) to use this
corrected planFeatures value so entitlements are created from the matched plan
version.
🪄 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: 7c441213-41dc-4c2c-969d-6e5da7092d0b

📥 Commits

Reviewing files that changed from the base of the PR and between b0468b2 and b2aee34.

📒 Files selected for processing (3)
  • packages/paykit/src/product/product.service.ts
  • packages/paykit/src/subscription/subscription.service.ts
  • packages/paykit/src/subscription/subscription.types.ts
✅ Files skipped from review due to trivial changes (1)
  • packages/paykit/src/subscription/subscription.types.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/paykit/src/product/product.service.ts

Comment on lines +100 to +105
const matchingProduct = input.productInternalId
? await getProductByInternalId(ctx.database, input.productInternalId)
: normalizedPlan
? await getProductByHash(ctx.database, input.planId, normalizedPlan.hash)
: null;
const storedPlan = matchingProduct ? withProviderInfo(matchingProduct, providerId) : 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.

⚠️ Potential issue | 🟠 Major

Validate that productInternalId belongs to planId.

When productInternalId is present, this branch resolves the stored product without checking matchingProduct.id === input.planId. The rest of loadSubscribeContext() still uses input.planId/normalizedPlan, so a mismatched caller can resolve one stored product while pricing or entitling against another.

Suggested guard
   const matchingProduct = input.productInternalId
     ? await getProductByInternalId(ctx.database, input.productInternalId)
     : normalizedPlan
       ? await getProductByHash(ctx.database, input.planId, normalizedPlan.hash)
       : null;
+  if (matchingProduct && matchingProduct.id !== input.planId) {
+    throw PayKitError.from(
+      "BAD_REQUEST",
+      PAYKIT_ERROR_CODES.PROVIDER_WEBHOOK_INVALID,
+      `Product "${matchingProduct.internalId}" does not belong to plan "${input.planId}"`,
+    );
+  }
   const storedPlan = matchingProduct ? withProviderInfo(matchingProduct, providerId) : null;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/paykit/src/subscription/subscription.service.ts` around lines 100 -
105, The code resolves matchingProduct from input.productInternalId without
verifying it belongs to the requested planId, which can cause mismatched product
vs plan usage in loadSubscribeContext; update the branch that fetches by
getProductByInternalId to validate that the returned matchingProduct.id (or
matchingProduct.internalPlanId equivalent) equals input.planId (or the
normalizedPlan.id/hash as appropriate) and if it does not, treat it as a
mismatch—either set matchingProduct to null and surface a clear validation error
or throw a BadRequest/ValidationError; ensure this check occurs before calling
withProviderInfo so providerId resolution uses a verified product.

@maxktz maxktz merged commit 0a2b2e8 into main Apr 13, 2026
3 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.

fix(CLI): ensure no downtime on push possible

1 participant