Skip to content

Remove Polar and make PayKit Stripe-only#195

Open
maxktz wants to merge 1 commit into
mainfrom
feat/polar-remove
Open

Remove Polar and make PayKit Stripe-only#195
maxktz wants to merge 1 commit into
mainfrom
feat/polar-remove

Conversation

@maxktz
Copy link
Copy Markdown
Contributor

@maxktz maxktz commented Jun 2, 2026

Summary

  • remove Polar and standalone provider packages, moving Stripe support into PayKit core
  • switch createPayKit configuration to required built-in Stripe options
  • simplify demo app config, scripts, routes, docs, and e2e harness for Stripe-only usage
  • add Stripe-only schema migration and update logic touching provider identifiers

Verification

  • pnpm --filter paykitjs typecheck
  • pnpm --filter e2e typecheck
  • pnpm test:unit -- packages/paykit/src/api/tests/methods.test.ts
  • focused Stripe e2e reruns for the previously failing specs
  • full Stripe e2e suite: 19 passed / 19 files, 21 passed / 21 tests
  • git diff --check

Summary by CodeRabbit

  • New Features

    • Stripe configuration is now built-in and configured directly via stripe option in createPayKit({ stripe: { secretKey, webhookSecret } }), eliminating the need for separate provider packages.
  • Refactor

    • Removed multi-provider architecture; PayKit now uses Stripe exclusively with dedicated database columns and schema migration.
    • Simplified configuration approach with Stripe credentials embedded directly in PayKit options.
  • Documentation

    • Updated installation, CLI, and provider guides to reflect Stripe-only setup.
    • Removed documentation for Polar and other planned providers.
  • Chores

    • Removed @paykitjs/stripe and @paykitjs/polar packages; Stripe support is now integrated into core.
    • Simplified test infrastructure and demo setup.

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Jun 2, 2026

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

Project Deployment Actions Updated (UTC)
paykit Ready Ready Preview, Comment Jun 2, 2026 6:47am

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 2, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

Refactors PayKit to a Stripe-only architecture: updates core options/provider, database schema/migrations, services, webhook/API handling, CLI, demo app wiring/UI/env, documentation, e2e harness/tests, and removes Polar and @paykitjs/stripe packages.

Changes

Stripe-only refactor and ecosystem updates

Layer / File(s) Summary
Stripe-only architecture, schema, runtime, tooling, and docs
packages/paykit/..., apps/demo/..., apps/web/..., e2e/..., packages/polar/* (removed), packages/stripe/* (removed), scripts/publish-dist.mjs, README.md
Consolidates to options.stripe with a new Stripe adapter, migrates DB to Stripe IDs, rewires services/API/webhooks/CLI, unifies demo route/client/UI/env, updates docs/examples/tests, and removes Polar/adapter packages and publish targets.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant NextRoute(/paykit)
  participant PayKit
  participant Stripe
  Client->>NextRoute(/paykit): HTTP GET/POST
  NextRoute(/paykit)->>PayKit: paykit.handler(request)
  PayKit->>Stripe: API/webhook interactions
  Stripe-->>PayKit: events/responses
  PayKit-->>NextRoute(/paykit): response
  NextRoute(/paykit)-->>Client: HTTP response
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • getpaykit/paykit#173 — Touches webhook/tunnel flows that align with this PR’s webhook/provider contract updates.
  • getpaykit/paykit#157 — Also modifies e2e/vitest config; relevant to this PR’s e2e runner changes.
  • getpaykit/paykit#139 — Earlier provider-layer refactors precede this Stripe-only consolidation.

Poem

A rabbit taps the keys—stripe bright, not dull,
Plans hop in code, with webhooks in a lull.
One path, one warren, no forks to patrol—
Migrations burrow deep, services roll.
Tabs trimmed, tests sing, docs sparkle anew—
Thump-thump! PayKit’s Stripe trail, sure and true. 🐇✨

✨ 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/polar-remove

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: 6

Caution

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

⚠️ Outside diff range comments (6)
apps/web/src/components/sections/readme-code-content.ts (1)

28-40: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Stripe wiring update LGTM, but the example callback uses await without async.

Lines 36-38 use await sendEmail(...) inside a non-async arrow, which is a syntax error for anyone copying this hero snippet.

📝 Proposed fix
   on: {
-    "subscription.activated": ({ customer, plan }) => {
+    "subscription.activated": async ({ customer, plan }) => {
       await sendEmail(customer.email, "Welcome to Pro!")
     },
   }
🤖 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 `@apps/web/src/components/sections/readme-code-content.ts` around lines 28 -
40, The event handler for "subscription.activated" in the createPayKit call uses
await inside a non-async arrow function causing a syntax error; update the
handler for that event (the "subscription.activated" callback used in
paykit/createPayKit) to be async (e.g., make the arrow async: async ({ customer,
plan }) => { await sendEmail(...) }) or remove the await and return the promise
from sendEmail, ensuring sendEmail is invoked correctly.
packages/paykit/src/core/create-paykit.ts (1)

68-90: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Attach a rejection handler to the cached context promise to prevent unhandledRejection.

initContext() can reject (e.g., pending migrations). In createPayKit, contextPromise = initContext(options) is created eagerly and passed into getApi, but wrapMethods only awaits the ctx promise inside each method wrapper (and handler()/$context do the same), so no immediate .catch/await is attached at context creation time. This can allow Node to emit unhandledRejection before any caller invokes a method/handler.

Add a rejection handler right after setting contextPromise (e.g., contextPromise.catch(() => {}), or equivalent) so the rejection is handled without swallowing the error.

🤖 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/core/create-paykit.ts` around lines 68 - 90, The cached
context promise created in createPayKit (contextPromise assigned via initContext
in getContext) can reject and cause unhandledRejection because no handler is
attached immediately; after you set contextPromise (contextPromise ??=
initContext(options)) attach a rejection handler (e.g., contextPromise.catch(()
=> {})) so the rejection is handled right away while preserving the existing
behavior where wrappers (handler/$context/wrapped API methods produced by
getApi/wrapMethods) still await the promise; ensure you add this line in the
getContext scope right after the assignment to contextPromise.
packages/paykit/src/cli/commands/status.ts (1)

80-86: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Inconsistent section label: error path still says Provider.

Success output (Lines 150, 156) was renamed to Stripe, but this failure block still prints Provider/Fix provider issues. Align for consistency.

Proposed fix
-    p.log.error(`Provider\n  ${picocolors.red("✖")} ${providerResult.account.message}`);
-    p.outro("Fix provider issues before continuing");
+    p.log.error(`Stripe\n  ${picocolors.red("✖")} ${providerResult.account.message}`);
+    p.outro("Fix Stripe issues before continuing");
🤖 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/cli/commands/status.ts` around lines 80 - 86, The failure
block prints "Provider" and "Fix provider issues" but the success messages were
renamed to "Stripe"; update the error section to match by replacing the
"Provider" label and the outro text accordingly. Locate the block that checks
providerResult.account.ok (uses s.stop(""), p.log.error, p.outro, await
database.end(), process.exit) and change the displayed label in p.log.error from
"Provider" to "Stripe" and update p.outro to "Fix Stripe issues before
continuing" so the success and failure texts are consistent.
packages/paykit/src/cli/commands/push.ts (1)

49-54: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Inconsistent section label: error path still says Provider.

The success path (Line 96) and config errors now use the Stripe label, but this failure block still prints Provider. Align it for consistent CLI output.

Proposed fix
-      p.log.error(`Provider\n  ${picocolors.red("✖")} ${providerResult.account.message}`);
+      p.log.error(`Stripe\n  ${picocolors.red("✖")} ${providerResult.account.message}`);
🤖 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/cli/commands/push.ts` around lines 49 - 54, The error
block that checks providerResult.account.ok prints the wrong section label
"Provider"; update the log to use the "Stripe" label for consistency with the
success and config error paths by changing the string passed to p.log.error from
`Provider\n  ${picocolors.red("✖")} ...` to `Stripe\n  ${picocolors.red("✖")}
${providerResult.account.message}`, leaving the surrounding calls (s.stop(""),
p.cancel("Push failed"), process.exit(1)) and symbols
(providerResult.account.ok, s.stop, p.log.error, picocolors.red, p.cancel,
process.exit) unchanged.
packages/paykit/src/webhook/webhook.api.ts (1)

17-22: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Confirm whether the legacy PAYKIT_ALLOW_STALE_SIGNATURES env var is still intended.

The rename from allowStaleSignaturesallowUnsignedPayload carried over the old env var on Line 19 alongside the new PAYKIT_ALLOW_UNSIGNED_PAYLOADS. If this is intentional backward compatibility it should be documented with a short comment; otherwise it's a dangling reference to a removed flag that silently keeps a signature-bypass path alive.

🔧 If backward compat is not intended
   return (
     process.env.PAYKIT_ALLOW_UNSIGNED_PAYLOADS === "1" ||
-    process.env.PAYKIT_ALLOW_STALE_SIGNATURES === "1" ||
     process.env.NODE_ENV === "development" ||
     process.env.NODE_ENV === "test"
   );
🤖 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/webhook/webhook.api.ts` around lines 17 - 22, The
conditional that checks environment variables in webhook.api.ts still references
the legacy PAYKIT_ALLOW_STALE_SIGNATURES alongside
PAYKIT_ALLOW_UNSIGNED_PAYLOADS; either remove the legacy reference or explicitly
document it: if backward compatibility is intended, add a short inline comment
next to the return expression explaining that PAYKIT_ALLOW_STALE_SIGNATURES is
kept for legacy support and should be removed in a future major; if not
intended, delete the PAYKIT_ALLOW_STALE_SIGNATURES check from the return
expression so only PAYKIT_ALLOW_UNSIGNED_PAYLOADS (and NODE_ENV checks) control
unsigned-stub behavior.
packages/paykit/src/stripe/stripe-provider.ts (1)

943-955: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Move the signature guard behind allowUnsignedPayload.

allowUnsignedPayload is checked only after PROVIDER_SIGNATURE_MISSING can already be thrown, so the new flag never works for unsigned deliveries. packages/paykit/src/cli/commands/listen.ts now relies on this path for direct tunnel replays.

Suggested fix
     async handleWebhook(data) {
-      const headerKey = Object.keys(data.headers).find(
-        (k) => k.toLowerCase() === "stripe-signature",
-      );
-      const signature = headerKey ? data.headers[headerKey] : undefined;
-      if (!signature) {
-        throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.PROVIDER_SIGNATURE_MISSING);
-      }
-
-      const event = data.allowUnsignedPayload
-        ? (JSON.parse(data.body) as StripeSdk.Event)
-        : await client.webhooks.constructEventAsync(data.body, signature, options.webhookSecret);
+      const event = data.allowUnsignedPayload
+        ? (JSON.parse(data.body) as StripeSdk.Event)
+        : await (async () => {
+            const headerKey = Object.keys(data.headers).find(
+              (k) => k.toLowerCase() === "stripe-signature",
+            );
+            const signature = headerKey ? data.headers[headerKey] : undefined;
+            if (!signature) {
+              throw PayKitError.from(
+                "BAD_REQUEST",
+                PAYKIT_ERROR_CODES.PROVIDER_SIGNATURE_MISSING,
+              );
+            }
+            return client.webhooks.constructEventAsync(
+              data.body,
+              signature,
+              options.webhookSecret,
+            );
+          })();
       return [
🤖 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/stripe/stripe-provider.ts` around lines 943 - 955, In
handleWebhook, the signature missing guard runs before allowUnsignedPayload is
checked so unsigned deliveries are blocked; change the flow in the handleWebhook
method to first branch on data.allowUnsignedPayload and, if true, parse JSON
from data.body into a StripeSdk.Event, otherwise perform the existing signature
lookup (headerKey/signature) and throw the PayKitError with
PAYKIT_ERROR_CODES.PROVIDER_SIGNATURE_MISSING if no signature, then call
client.webhooks.constructEventAsync(...) with data.body, signature and
options.webhookSecret; keep all existing variable names (headerKey, signature,
data.allowUnsignedPayload, PAYKIT_ERROR_CODES.PROVIDER_SIGNATURE_MISSING,
client.webhooks.constructEventAsync) and return the event as before.
🧹 Nitpick comments (3)
e2e/cli/push.test.ts (1)

85-93: 💤 Low value

Nit: providerRows name is stale.

The rows no longer come from a provider field; consider proRows for clarity.

🤖 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 `@e2e/cli/push.test.ts` around lines 85 - 93, Rename the stale variable
providerRows to proRows in the select block and update its use when extracting
the first row into proProduct; specifically update the identifier providerRows
in the lines using ctx.database.select(...).from(product)... to proRows and
adjust any subsequent references (e.g., the assignment to proProduct) so the new
name is used consistently.
packages/paykit/src/cli/utils/shared.ts (1)

181-186: ⚖️ Poor tradeoff

checkActiveSubscriptionsOnOtherProvider is now a no-op.

It ignores both params and always returns [], yet callers in push.ts/status.ts still compute providerId = "stripe" and await it as a preflight check. In a Stripe-only world this cross-provider check is dead. Consider removing the function and its call sites in a follow-up to avoid misleading preflight semantics.

🤖 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/cli/utils/shared.ts` around lines 181 - 186, The function
checkActiveSubscriptionsOnOtherProvider is a no-op and causes misleading
preflight behavior; remove the function and its call sites instead of leaving it
returning [] — locate references to checkActiveSubscriptionsOnOtherProvider in
push.ts and status.ts (where providerId is computed as "stripe"), delete the
await calls and any related handling, and remove the import/export of
checkActiveSubscriptionsOnOtherProvider from
packages/paykit/src/cli/utils/shared.ts; ensure any leftover variables (e.g.,
providerId or returned values) are cleaned up or adjusted and run tests to
confirm no unused-import/variable errors.
packages/paykit/src/cli/commands/init.ts (1)

551-552: Open TODO for paykitjs listen.

The forwarded webhook command is hardcoded to the Stripe CLI with a TODO to swap in paykitjs listen.

Want me to open an issue to track replacing this with paykitjs listen?

🤖 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/cli/commands/init.ts` around lines 551 - 552, Replace the
hardcoded webhookCommand string with a small helper that prefers the new paykit
CLI: implement a getWebhookListenCommand() (and optional detectPaykitCli()) and
assign webhookCommand = getWebhookListenCommand(port) so it returns "paykitjs
listen --forward-to localhost:3000/paykit/webhook" when the paykit CLI is
available and falls back to "stripe listen --forward-to ..." otherwise; update
the TODO by removing the hardcoded string and ensuring the helper is referenced
from init.ts (webhookCommand) and covered by a simple unit test for both code
paths.
🤖 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 `@e2e/test-utils/harness/stripe.ts`:
- Around line 91-94: The current use of cardPaymentButton.evaluate(...click())
bypasses Playwright actionability checks; keep the existing count() conditional
but replace the evaluate call with an actionable locator click: use
cardPaymentButton.first().waitFor({ state: "visible" }) (or waitFor({ state:
"enabled" }) as appropriate) and then await cardPaymentButton.first().click() so
the click uses Playwright's built-in waits and retries; update the code in the
block referencing the cardPaymentButton locator accordingly.

In `@packages/paykit/src/cli/commands/listen.ts`:
- Around line 94-106: The function getEnvStripeOptions currently returns a fake
"whsec_placeholder" when no webhook secret env var exists; change it to
explicitly represent "unknown" by returning webhookSecret as undefined (and
update the function signature to allow webhookSecret?: string | undefined)
instead of synthesizing a value, and then ensure callers (e.g.,
ensureTunnelWebhook in stripe-provider.ts and any code that prints
options.webhookSecret) treat undefined as "secret unknown" and avoid
echoing/saving a placeholder.
- Around line 682-692: The current catch around getPayKitConfig() treats every
error as "no config" and falls back to envs; change it to only fall back when
the error is a genuine "config not found" case and rethrow all other errors
(syntax/validation/missing env) so they surface. Implement a check (e.g., add or
use an isConfigNotFound(error) helper or test error.name/message/code) inside
the catch for getPayKitConfig(...) — if isConfigNotFound(error) is true then
call loadDotEnv(params.cwd) and stripeOptions = getEnvStripeOptions(), otherwise
throw the error (respecting params.configPath || params.requireConfig logic).
Ensure references: getPayKitConfig, params.configPath, params.requireConfig,
loadDotEnv, getEnvStripeOptions, basePath, stripeOptions.

In `@packages/paykit/src/database/migrations/0001_stripe_only_schema.sql`:
- Around line 65-70: The migration adds a new column paykit_payment_method.brand
but never backfills it from the existing JSON provider_data, so dropping
provider_data will lose historical brand values; update the migration (around
ALTER TABLE "paykit_payment_method" ADD COLUMN "brand" text and the related
blocks at the other ranges) to run an UPDATE that sets brand =
provider_data->>'brand' for all rows (and ensure NULL-safe handling), verify the
backfill completes before any DROP COLUMN "provider_data" statements, and apply
the same backfill logic for the other affected migration sections (lines
referenced 89-104 and 147-148) so historical brand data is preserved.
- Around line 55-60: The migration currently adds stripe_frozen_time as type
timestamp which drops the Z/UTC offset; change the ALTER TABLE that adds
stripe_frozen_time on paykit_customer to use timestamptz instead of timestamp,
and update any related backfill/CAST logic (the backfill that converts
ISO-8601/Z values into stripe_frozen_time) to cast to timestamptz so the
original instant is preserved when reading/writing from node-postgres; ensure
the column name stripe_frozen_time and the ALTER TABLE statement are the only
places modified.

In `@packages/paykit/src/payment-method/payment-method.service.ts`:
- Around line 49-54: The current lookup in
database.query.paymentMethod.findFirst excludes soft-deleted rows
(isNull(paymentMethod.deletedAt)), so after deletePaymentMethodByProviderId a
later upsert creates a duplicate; change the upsert flow to include soft-deleted
rows by removing the isNull filter (or explicitly searching both deleted and
non-deleted) when querying by stripePaymentMethodId
(paymentMethod.stripePaymentMethodId) and if a row is found with deletedAt set,
perform an update to clear deletedAt and refresh the record fields instead of
inserting a new row; keep the existing update path that clears deletedAt intact
and ensure deletePaymentMethodByProviderId still sets deletedAt only.

---

Outside diff comments:
In `@apps/web/src/components/sections/readme-code-content.ts`:
- Around line 28-40: The event handler for "subscription.activated" in the
createPayKit call uses await inside a non-async arrow function causing a syntax
error; update the handler for that event (the "subscription.activated" callback
used in paykit/createPayKit) to be async (e.g., make the arrow async: async ({
customer, plan }) => { await sendEmail(...) }) or remove the await and return
the promise from sendEmail, ensuring sendEmail is invoked correctly.

In `@packages/paykit/src/cli/commands/push.ts`:
- Around line 49-54: The error block that checks providerResult.account.ok
prints the wrong section label "Provider"; update the log to use the "Stripe"
label for consistency with the success and config error paths by changing the
string passed to p.log.error from `Provider\n  ${picocolors.red("✖")} ...` to
`Stripe\n  ${picocolors.red("✖")} ${providerResult.account.message}`, leaving
the surrounding calls (s.stop(""), p.cancel("Push failed"), process.exit(1)) and
symbols (providerResult.account.ok, s.stop, p.log.error, picocolors.red,
p.cancel, process.exit) unchanged.

In `@packages/paykit/src/cli/commands/status.ts`:
- Around line 80-86: The failure block prints "Provider" and "Fix provider
issues" but the success messages were renamed to "Stripe"; update the error
section to match by replacing the "Provider" label and the outro text
accordingly. Locate the block that checks providerResult.account.ok (uses
s.stop(""), p.log.error, p.outro, await database.end(), process.exit) and change
the displayed label in p.log.error from "Provider" to "Stripe" and update
p.outro to "Fix Stripe issues before continuing" so the success and failure
texts are consistent.

In `@packages/paykit/src/core/create-paykit.ts`:
- Around line 68-90: The cached context promise created in createPayKit
(contextPromise assigned via initContext in getContext) can reject and cause
unhandledRejection because no handler is attached immediately; after you set
contextPromise (contextPromise ??= initContext(options)) attach a rejection
handler (e.g., contextPromise.catch(() => {})) so the rejection is handled right
away while preserving the existing behavior where wrappers
(handler/$context/wrapped API methods produced by getApi/wrapMethods) still
await the promise; ensure you add this line in the getContext scope right after
the assignment to contextPromise.

In `@packages/paykit/src/stripe/stripe-provider.ts`:
- Around line 943-955: In handleWebhook, the signature missing guard runs before
allowUnsignedPayload is checked so unsigned deliveries are blocked; change the
flow in the handleWebhook method to first branch on data.allowUnsignedPayload
and, if true, parse JSON from data.body into a StripeSdk.Event, otherwise
perform the existing signature lookup (headerKey/signature) and throw the
PayKitError with PAYKIT_ERROR_CODES.PROVIDER_SIGNATURE_MISSING if no signature,
then call client.webhooks.constructEventAsync(...) with data.body, signature and
options.webhookSecret; keep all existing variable names (headerKey, signature,
data.allowUnsignedPayload, PAYKIT_ERROR_CODES.PROVIDER_SIGNATURE_MISSING,
client.webhooks.constructEventAsync) and return the event as before.

In `@packages/paykit/src/webhook/webhook.api.ts`:
- Around line 17-22: The conditional that checks environment variables in
webhook.api.ts still references the legacy PAYKIT_ALLOW_STALE_SIGNATURES
alongside PAYKIT_ALLOW_UNSIGNED_PAYLOADS; either remove the legacy reference or
explicitly document it: if backward compatibility is intended, add a short
inline comment next to the return expression explaining that
PAYKIT_ALLOW_STALE_SIGNATURES is kept for legacy support and should be removed
in a future major; if not intended, delete the PAYKIT_ALLOW_STALE_SIGNATURES
check from the return expression so only PAYKIT_ALLOW_UNSIGNED_PAYLOADS (and
NODE_ENV checks) control unsigned-stub behavior.

---

Nitpick comments:
In `@e2e/cli/push.test.ts`:
- Around line 85-93: Rename the stale variable providerRows to proRows in the
select block and update its use when extracting the first row into proProduct;
specifically update the identifier providerRows in the lines using
ctx.database.select(...).from(product)... to proRows and adjust any subsequent
references (e.g., the assignment to proProduct) so the new name is used
consistently.

In `@packages/paykit/src/cli/commands/init.ts`:
- Around line 551-552: Replace the hardcoded webhookCommand string with a small
helper that prefers the new paykit CLI: implement a getWebhookListenCommand()
(and optional detectPaykitCli()) and assign webhookCommand =
getWebhookListenCommand(port) so it returns "paykitjs listen --forward-to
localhost:3000/paykit/webhook" when the paykit CLI is available and falls back
to "stripe listen --forward-to ..." otherwise; update the TODO by removing the
hardcoded string and ensuring the helper is referenced from init.ts
(webhookCommand) and covered by a simple unit test for both code paths.

In `@packages/paykit/src/cli/utils/shared.ts`:
- Around line 181-186: The function checkActiveSubscriptionsOnOtherProvider is a
no-op and causes misleading preflight behavior; remove the function and its call
sites instead of leaving it returning [] — locate references to
checkActiveSubscriptionsOnOtherProvider in push.ts and status.ts (where
providerId is computed as "stripe"), delete the await calls and any related
handling, and remove the import/export of
checkActiveSubscriptionsOnOtherProvider from
packages/paykit/src/cli/utils/shared.ts; ensure any leftover variables (e.g.,
providerId or returned values) are cleaned up or adjusted and run tests to
confirm no unused-import/variable errors.
🪄 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: 1059bfa6-d80b-4f37-9f95-137993970930

📥 Commits

Reviewing files that changed from the base of the PR and between 6dac3b5 and 2a18aed.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (103)
  • README.md
  • apps/demo/drizzle.config.ts
  • apps/demo/next.config.js
  • apps/demo/package.json
  • apps/demo/paykit.config.ts
  • apps/demo/paykit.polar.config.ts
  • apps/demo/paykit.stripe.config.ts
  • apps/demo/scripts/push-sandbox.ts
  • apps/demo/scripts/sandbox.ts
  • apps/demo/src/app/_components/checkout-page-content.tsx
  • apps/demo/src/app/_components/features-panel.tsx
  • apps/demo/src/app/_components/subscribe-panel.tsx
  • apps/demo/src/app/paykit-polar/[[...slug]]/route.ts
  • apps/demo/src/app/paykit-stripe/[[...slug]]/route.ts
  • apps/demo/src/app/paykit/[[...slug]]/route.ts
  • apps/demo/src/env.js
  • apps/demo/src/lib/paykit-client.ts
  • apps/demo/src/lib/paykit-scenarios.ts
  • apps/demo/src/lib/paykit.ts
  • apps/demo/src/lib/paykit/polar.ts
  • apps/demo/src/lib/paykit/stripe.ts
  • apps/demo/src/lib/scenario-config.ts
  • apps/demo/src/server/api/root.ts
  • apps/demo/src/server/api/routers/paykit-route.ts
  • apps/demo/src/server/db.ts
  • apps/web/content/docs/concepts/cli.mdx
  • apps/web/content/docs/concepts/payment-providers.mdx
  • apps/web/content/docs/get-started/index.mdx
  • apps/web/content/docs/get-started/installation.mdx
  • apps/web/content/docs/providers/creem.mdx
  • apps/web/content/docs/providers/lemonsqueezy.mdx
  • apps/web/content/docs/providers/meta.json
  • apps/web/content/docs/providers/paddle.mdx
  • apps/web/content/docs/providers/paypal.mdx
  • apps/web/content/docs/providers/polar.mdx
  • apps/web/content/docs/providers/stripe.mdx
  • apps/web/src/components/docs/docs-icons.tsx
  • apps/web/src/components/docs/features.tsx
  • apps/web/src/components/sections/demo/demo-types.tsx
  • apps/web/src/components/sections/features-section.tsx
  • apps/web/src/components/sections/readme-code-content.ts
  • e2e/cli/init.test.ts
  • e2e/cli/push.test.ts
  • e2e/cli/setup.ts
  • e2e/cli/status.test.ts
  • e2e/core/subscribe/cancel-end-of-cycle.test.ts
  • e2e/core/subscribe/renewal.test.ts
  • e2e/core/webhook/duplicate-webhook.test.ts
  • e2e/core/webhook/subscription-deleted.test.ts
  • e2e/package.json
  • e2e/test-utils/env.ts
  • e2e/test-utils/harness/index.ts
  • e2e/test-utils/harness/polar.ts
  • e2e/test-utils/harness/stripe.ts
  • e2e/test-utils/harness/types.ts
  • e2e/test-utils/setup.ts
  • e2e/vitest.config.ts
  • packages/paykit/package.json
  • packages/paykit/src/api/__tests__/define-route.test.ts
  • packages/paykit/src/api/__tests__/methods.test.ts
  • packages/paykit/src/api/methods.ts
  • packages/paykit/src/cli/commands/init.ts
  • packages/paykit/src/cli/commands/listen.ts
  • packages/paykit/src/cli/commands/push.ts
  • packages/paykit/src/cli/commands/status.ts
  • packages/paykit/src/cli/utils/get-config.ts
  • packages/paykit/src/cli/utils/shared.ts
  • packages/paykit/src/core/__tests__/context.test.ts
  • packages/paykit/src/core/__tests__/create-paykit.test.ts
  • packages/paykit/src/core/context.ts
  • packages/paykit/src/core/create-paykit.ts
  • packages/paykit/src/customer/__tests__/customer.service.test.ts
  • packages/paykit/src/customer/customer.service.ts
  • packages/paykit/src/database/migrations/0001_stripe_only_schema.sql
  • packages/paykit/src/database/migrations/meta/0001_snapshot.json
  • packages/paykit/src/database/migrations/meta/_journal.json
  • packages/paykit/src/database/schema.ts
  • packages/paykit/src/index.ts
  • packages/paykit/src/invoice/invoice.service.ts
  • packages/paykit/src/payment-method/payment-method.service.ts
  • packages/paykit/src/payment/payment.service.ts
  • packages/paykit/src/product/product.service.ts
  • packages/paykit/src/providers/provider.ts
  • packages/paykit/src/stripe/stripe-provider.ts
  • packages/paykit/src/subscription/subscription.service.ts
  • packages/paykit/src/testing/testing.service.ts
  • packages/paykit/src/types/instance.ts
  • packages/paykit/src/types/options.ts
  • packages/paykit/src/utilities/dependencies/paykit-package-list.ts
  • packages/paykit/src/webhook/webhook.api.ts
  • packages/paykit/src/webhook/webhook.service.ts
  • packages/polar/package.json
  • packages/polar/src/index.ts
  • packages/polar/src/polar-provider.ts
  • packages/polar/tsconfig.json
  • packages/polar/tsdown.config.ts
  • packages/stripe/package.json
  • packages/stripe/src/__tests__/stripe-provider.test.ts
  • packages/stripe/src/__tests__/stripe.test.ts
  • packages/stripe/src/index.ts
  • packages/stripe/tsconfig.json
  • packages/stripe/tsdown.config.ts
  • scripts/publish-dist.mjs
💤 Files with no reviewable changes (27)
  • apps/demo/src/lib/paykit/stripe.ts
  • packages/stripe/tsconfig.json
  • packages/stripe/src/tests/stripe.test.ts
  • apps/web/content/docs/providers/lemonsqueezy.mdx
  • apps/web/content/docs/providers/creem.mdx
  • apps/web/content/docs/providers/paypal.mdx
  • packages/stripe/src/index.ts
  • apps/demo/src/lib/paykit/polar.ts
  • apps/web/content/docs/providers/polar.mdx
  • apps/demo/paykit.stripe.config.ts
  • apps/demo/src/lib/paykit-scenarios.ts
  • packages/stripe/package.json
  • packages/polar/tsconfig.json
  • apps/web/content/docs/providers/paddle.mdx
  • e2e/test-utils/harness/polar.ts
  • packages/polar/tsdown.config.ts
  • packages/stripe/tsdown.config.ts
  • packages/paykit/src/testing/testing.service.ts
  • packages/stripe/src/tests/stripe-provider.test.ts
  • packages/polar/src/polar-provider.ts
  • e2e/package.json
  • apps/demo/src/app/paykit-stripe/[[...slug]]/route.ts
  • apps/demo/src/app/paykit-polar/[[...slug]]/route.ts
  • packages/polar/package.json
  • packages/polar/src/index.ts
  • apps/demo/paykit.polar.config.ts
  • apps/demo/src/lib/scenario-config.ts

Comment on lines +91 to +94
const cardPaymentButton = page.locator('[data-testid="card-accordion-item-button"]');
if ((await cardPaymentButton.count()) > 0) {
await cardPaymentButton.evaluate((el) => (el as HTMLElement).click());
}
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 | ⚡ Quick win

Use locator-based clicks (with visible/enabled waits) for the Stripe card accordion control

In e2e/test-utils/harness/stripe.ts (lines 91-94), cardPaymentButton.evaluate(el => (el as HTMLElement).click()) bypasses Playwright’s actionability checks/retry. Keep the optional count()/conditional logic, but switch to await cardPaymentButton.first().waitFor({ state: "visible" }) + await cardPaymentButton.first().click() (or an equivalent enabled/ready wait) to avoid no-op clicks and downstream flakiness.

🤖 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 `@e2e/test-utils/harness/stripe.ts` around lines 91 - 94, The current use of
cardPaymentButton.evaluate(...click()) bypasses Playwright actionability checks;
keep the existing count() conditional but replace the evaluate call with an
actionable locator click: use cardPaymentButton.first().waitFor({ state:
"visible" }) (or waitFor({ state: "enabled" }) as appropriate) and then await
cardPaymentButton.first().click() so the click uses Playwright's built-in waits
and retries; update the code in the block referencing the cardPaymentButton
locator accordingly.

Comment on lines +94 to +106
function getEnvStripeOptions(): { secretKey: string; webhookSecret: string } {
const secretKey = process.env.E2E_STRIPE_SK ?? process.env.STRIPE_SECRET_KEY;
if (!secretKey) {
throw new Error(
"No PayKit config found and no Stripe secret key found in env. Set E2E_STRIPE_SK or STRIPE_SECRET_KEY, or pass --config.",
);
}

return {
secretKey,
webhookSecret:
process.env.E2E_STRIPE_WHSEC ?? process.env.STRIPE_WEBHOOK_SECRET ?? "whsec_placeholder",
};
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 | 🏗️ Heavy lift

Avoid inventing a webhook secret.

When STRIPE_WEBHOOK_SECRET is absent this returns "whsec_placeholder", and packages/paykit/src/stripe/stripe-provider.ts echoes options.webhookSecret back from ensureTunnelWebhook() when it updates an existing endpoint. In paykitjs listen --forward-to, that placeholder can then be printed as if it were the real signing secret, which nudges users to save an invalid value and breaks later direct delivery. Handle "secret unknown" explicitly instead of synthesizing one.

🤖 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/cli/commands/listen.ts` around lines 94 - 106, The
function getEnvStripeOptions currently returns a fake "whsec_placeholder" when
no webhook secret env var exists; change it to explicitly represent "unknown" by
returning webhookSecret as undefined (and update the function signature to allow
webhookSecret?: string | undefined) instead of synthesizing a value, and then
ensure callers (e.g., ensureTunnelWebhook in stripe-provider.ts and any code
that prints options.webhookSecret) treat undefined as "secret unknown" and avoid
echoing/saving a placeholder.

Comment on lines +682 to +692
try {
config = await getPayKitConfig({ configPath: params.configPath, cwd: params.cwd });
basePath = config.options.basePath ?? basePath;
stripeOptions = config.options.stripe;
} catch (error) {
if (params.configPath || params.requireConfig) {
throw error;
}
loadDotEnv(params.cwd);
stripeOptions = getEnvStripeOptions();
}
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 | ⚡ Quick win

Don't downgrade config load failures to env fallback.

This catches every getPayKitConfig() failure when --forward-to is used. A config with missing env vars, syntax errors, or validation errors now gets treated like "no config", which falls back to the default base path and env Stripe credentials and can wire the tunnel to the wrong local route or Stripe account. Only fall back on a genuine "config not found" case.

🤖 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/cli/commands/listen.ts` around lines 682 - 692, The
current catch around getPayKitConfig() treats every error as "no config" and
falls back to envs; change it to only fall back when the error is a genuine
"config not found" case and rethrow all other errors (syntax/validation/missing
env) so they surface. Implement a check (e.g., add or use an
isConfigNotFound(error) helper or test error.name/message/code) inside the catch
for getPayKitConfig(...) — if isConfigNotFound(error) is true then call
loadDotEnv(params.cwd) and stripeOptions = getEnvStripeOptions(), otherwise
throw the error (respecting params.configPath || params.requireConfig logic).
Ensure references: getPayKitConfig, params.configPath, params.requireConfig,
loadDotEnv, getEnvStripeOptions, basePath, stripeOptions.

Comment on lines +55 to +60
ALTER TABLE "paykit_customer" ADD COLUMN "stripe_customer_id" text;--> statement-breakpoint
ALTER TABLE "paykit_customer" ADD COLUMN "stripe_test_clock_id" text;--> statement-breakpoint
ALTER TABLE "paykit_customer" ADD COLUMN "stripe_frozen_time" timestamp;--> statement-breakpoint
ALTER TABLE "paykit_customer" ADD COLUMN "stripe_synced_email" text;--> statement-breakpoint
ALTER TABLE "paykit_customer" ADD COLUMN "stripe_synced_name" text;--> statement-breakpoint
ALTER TABLE "paykit_customer" ADD COLUMN "stripe_synced_metadata" jsonb;--> statement-breakpoint
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 | 🏗️ Heavy lift

🧩 Analysis chain

🌐 Web query:

In PostgreSQL, what happens when an ISO 8601 string with a Zsuffix is cast totimestampversustimestamptz, and how are timestamp without time zonevalues typically deserialized into JavaScriptDate objects by Node/Postgres drivers?

💡 Result:

In PostgreSQL, casting an ISO 8601 string with a Z suffix (indicating UTC) to different timestamp types results in significant differences in how that value is processed and stored [1]: 1. Casting to timestamp (or timestamp without time zone): PostgreSQL ignores the Z suffix and any other time zone indicators [1]. It treats the input purely as calendar and clock fields, storing the values exactly as they appear in the string [1][2]. Consequently, no time zone adjustment occurs, and the UTC context is effectively discarded [1]. 2. Casting to timestamptz (or timestamp with time zone): PostgreSQL interprets the input as having a specific time zone [1]. Because the Z suffix explicitly denotes UTC, PostgreSQL converts the input time to UTC (if it is not already) and stores it as a UTC value [1]. Note that the original time zone is not retained in the stored data; the value is simply normalized to UTC for storage and, upon retrieval, adjusted to the session's current TimeZone setting for display [1][2]. Regarding deserialization in Node.js via drivers like node-postgres (pg): - By default, common drivers such as node-postgres automatically parse timestamp and timestamptz columns into JavaScript Date objects [3]. - For timestamp without time zone values, these drivers often interpret the string as being in the local time of the Node.js process (derived from process.env.TZ or the system clock) rather than UTC [3][4][5]. This can lead to discrepancies if your database stores UTC values in timestamp columns but your Node.js server runs in a different time zone [6][4][5]. - To avoid these issues, many developers override the default type parser for the timestamp type (OID 1114) to explicitly treat the incoming string as UTC [7][8]. A common approach is to append "+0000" to the raw string before passing it to the Date constructor, or by using Date.UTC to manually construct the date object, ensuring the absolute time value remains correct regardless of the local server environment [6][7]. Using timestamptz columns is generally recommended to mitigate these client-side deserialization ambiguities, as the driver handles them more predictably as UTC [2][3][5].

Citations:


Store stripe_frozen_time as timestamptz to preserve the Stripe instant.

Casting an ISO 8601 ...Z value to PostgreSQL timestamp (timestamp without time zone) ignores the Z, and node-postgres typically parses such values into JS Date as local/naive time—so round-tripping (e.g., via toISOString()) can shift the instant. Store as timestamptz and cast accordingly in the backfill.

💡 Suggested fix
-ALTER TABLE "paykit_customer" ADD COLUMN "stripe_frozen_time" timestamp;--> statement-breakpoint
+ALTER TABLE "paykit_customer" ADD COLUMN "stripe_frozen_time" timestamptz;--> statement-breakpoint
@@
-    WHEN "provider"->'stripe'->>'frozenTime' ~ '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}'
-      THEN ("provider"->'stripe'->>'frozenTime')::timestamp
+    WHEN "provider"->'stripe'->>'frozenTime' ~ '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}'
+      THEN ("provider"->'stripe'->>'frozenTime')::timestamptz
🤖 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/database/migrations/0001_stripe_only_schema.sql` around
lines 55 - 60, The migration currently adds stripe_frozen_time as type timestamp
which drops the Z/UTC offset; change the ALTER TABLE that adds
stripe_frozen_time on paykit_customer to use timestamptz instead of timestamp,
and update any related backfill/CAST logic (the backfill that converts
ISO-8601/Z values into stripe_frozen_time) to cast to timestamptz so the
original instant is preserved when reading/writing from node-postgres; ensure
the column name stripe_frozen_time and the ALTER TABLE statement are the only
places modified.

Comment on lines +65 to +70
ALTER TABLE "paykit_payment_method" ADD COLUMN "stripe_payment_method_id" text;--> statement-breakpoint
ALTER TABLE "paykit_payment_method" ADD COLUMN "type" text;--> statement-breakpoint
ALTER TABLE "paykit_payment_method" ADD COLUMN "brand" text;--> statement-breakpoint
ALTER TABLE "paykit_payment_method" ADD COLUMN "last4" text;--> statement-breakpoint
ALTER TABLE "paykit_payment_method" ADD COLUMN "expiry_month" integer;--> statement-breakpoint
ALTER TABLE "paykit_payment_method" ADD COLUMN "expiry_year" integer;--> statement-breakpoint
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 | ⚡ Quick win

Backfill brand before dropping provider_data.

brand is added here, but the backfill never copies provider_data->>'brand'. Once provider_data is dropped, every historical payment method loses its stored brand permanently.

💡 Suggested fix
 UPDATE "paykit_payment_method"
 SET
   "stripe_payment_method_id" = "provider_data"->>'methodId',
   "type" = "provider_data"->>'type',
+  "brand" = "provider_data"->>'brand',
   "last4" = "provider_data"->>'last4',
   "expiry_month" = CASE

Also applies to: 89-104, 147-148

🤖 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/database/migrations/0001_stripe_only_schema.sql` around
lines 65 - 70, The migration adds a new column paykit_payment_method.brand but
never backfills it from the existing JSON provider_data, so dropping
provider_data will lose historical brand values; update the migration (around
ALTER TABLE "paykit_payment_method" ADD COLUMN "brand" text and the related
blocks at the other ranges) to run an UPDATE that sets brand =
provider_data->>'brand' for all rows (and ensure NULL-safe handling), verify the
backfill completes before any DROP COLUMN "provider_data" statements, and apply
the same backfill logic for the other affected migration sections (lines
referenced 89-104 and 147-148) so historical brand data is preserved.

Comment on lines 49 to 54
const existingRow = await database.query.paymentMethod.findFirst({
where: and(
eq(paymentMethod.providerId, input.providerId),
sql`${paymentMethod.providerData}->>'methodId' = ${input.paymentMethod.providerMethodId}`,
eq(paymentMethod.stripePaymentMethodId, input.paymentMethod.providerMethodId),
isNull(paymentMethod.deletedAt),
),
});
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 | ⚡ Quick win

Resurrect soft-deleted payment methods instead of inserting duplicates.

The update path clears deletedAt, but this lookup excludes deleted rows. After deletePaymentMethodByProviderId, a later upsert for the same Stripe payment method will insert a second record instead of reviving the original one, which breaks idempotency for detach/reattach or out-of-order webhook flows.

💡 Suggested fix
   const existingRow = await database.query.paymentMethod.findFirst({
-    where: and(
-      eq(paymentMethod.stripePaymentMethodId, input.paymentMethod.providerMethodId),
-      isNull(paymentMethod.deletedAt),
-    ),
+    where: eq(paymentMethod.stripePaymentMethodId, input.paymentMethod.providerMethodId),
   });
🤖 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/payment-method/payment-method.service.ts` around lines 49
- 54, The current lookup in database.query.paymentMethod.findFirst excludes
soft-deleted rows (isNull(paymentMethod.deletedAt)), so after
deletePaymentMethodByProviderId a later upsert creates a duplicate; change the
upsert flow to include soft-deleted rows by removing the isNull filter (or
explicitly searching both deleted and non-deleted) when querying by
stripePaymentMethodId (paymentMethod.stripePaymentMethodId) and if a row is
found with deletedAt set, perform an update to clear deletedAt and refresh the
record fields instead of inserting a new row; keep the existing update path that
clears deletedAt intact and ensure deletePaymentMethodByProviderId still sets
deletedAt only.

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