Skip to content

Refactor provider capabilities#194

Closed
maxktz wants to merge 2 commits into
mainfrom
feat/core-cleanup
Closed

Refactor provider capabilities#194
maxktz wants to merge 2 commits into
mainfrom
feat/core-cleanup

Conversation

@maxktz
Copy link
Copy Markdown
Contributor

@maxktz maxktz commented Jun 1, 2026

Summary

  • refactor PayKit providers around explicit capabilities and flattened provider methods
  • update Stripe and Polar adapters to the new provider surface
  • improve webhook endpoint/listen support and Polar E2E checkout handling
  • add capability tests and Polar provider tests

Verification

  • pnpm lint
  • pnpm typecheck
  • pnpm test:unit
  • Polar E2E supported tests pass; unsupported provider-capability tests skip

Notes

  • Stripe E2E was not rerun after the final commit in this session.

Summary by CodeRabbit

Release Notes

  • Documentation

    • Updated Polar provider setup instructions to use PayKit CLI for webhook configuration instead of Polar CLI.
  • Improvements

    • Enhanced provider capability system for more flexible and robust payment provider support.
    • Improved webhook endpoint management across payment providers.
    • Strengthened subscription lifecycle handling and checkout flows.
    • Refined E2E test infrastructure for better environment-specific feature validation.

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Jun 1, 2026

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

Project Deployment Actions Updated (UTC)
paykit Ready Ready Preview, Comment Jun 1, 2026 10:36pm

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jun 1, 2026

📝 Walkthrough

Walkthrough

PayKit's provider architecture transitions from an adapter-based pattern (PayKitProviderConfig, createAdapter()) to a capability-driven direct provider model. The new PayKitProvider generic type uses conditional TypeScript to select methods based on declared capabilities, eliminating the need for separate adapter creation. The webhook lifecycle shifts from tunnel-based to webhook-endpoint APIs. Stripe and Polar providers are refactored to return PayKitProvider directly, with updated subscription lifecycle methods and webhook normalization. E2E testing infrastructure introduces capability-based conditional skipping and polling-based synchronization alongside multi-mode checkout harnesses.

Changes

Provider Architecture Refactor to Capability-Driven Model

Layer / File(s) Summary
Provider Type System & Capability Model
packages/paykit/src/providers/provider.ts
PayKitProvider generic type replaces PaymentProvider, using conditional types to derive required/optional methods from PayKitProviderCapabilities. New types for webhook-endpoint management, products, and subscription schedules replace tunnel-based types. defineProvider() helper preserves exact capability typing.
Capability Assertion Helpers
packages/paykit/src/providers/capabilities.ts, packages/paykit/src/providers/__tests__/capabilities.test.ts
New unsupportedProviderCapability(), assertProviderCapability(), and generic assertProviderHasCapability() functions validate and enforce provider capability requirements with proper error handling and TypeScript type narrowing.
Core Context & Options Wiring
packages/paykit/src/core/context.ts, packages/paykit/src/types/options.ts
PayKitContext.provider type and PayKitOptions.provider type both change from PaymentProvider/PayKitProviderConfig to PayKitProvider; createContext() returns options.provider directly without calling createAdapter().
Error Codes & Public Exports
packages/paykit/src/core/errors.ts, packages/paykit/src/index.ts
New PROVIDER_CAPABILITY_UNSUPPORTED error code; public exports expanded to include new provider types (webhook-endpoint, product, schedule, health) and capability helpers; removed tunnel/config-related exports.
Customer & Product Services
packages/paykit/src/customer/customer.api.ts, packages/paykit/src/customer/customer.service.ts, packages/paykit/src/product/product-sync.service.ts
Customer portal asserts customerPortal capability; customer cleanup asserts capability for subscription operations; product sync asserts subscriptionProducts and switches from bulk syncProducts() to per-plan upsertSubscriptionProduct() calls.
Subscription Service Capability Gating
packages/paykit/src/subscription/subscription.service.ts
All provider operations now assert required capabilities (cancel-at-period-end, resume, create, checkout). New scheduleProviderProductChange() helper routes between schedule-based and pending-product-change strategies with capability-specific implementation.
Testing Service & Webhook Refactoring
packages/paykit/src/testing/testing.service.ts, packages/paykit/src/webhook/webhook.service.ts
Testing service uses assertProviderHasCapability() for test-clock checks; webhook service derives actions locally via getWebhookApplyActions() instead of from event; handleWebhook() calls provider.parseWebhook() instead of handleWebhook().
Webhook Endpoint & CLI Management
packages/paykit/src/cli/commands/listen.ts, packages/paykit/src/cli/utils/shared.ts
Replaces tunnel-based webhooks with webhook-endpoint APIs; ensureWebhookEndpoint() and deleteWebhookEndpoint() methods; CLI assertions and account retrieval updated; checkProvider() accepts PayKitProvider directly.
Stripe Provider Direct Implementation
packages/stripe/src/stripe-provider.ts, packages/stripe/src/__tests__/stripe-provider.test.ts, packages/stripe/src/__tests__/stripe.test.ts
stripe() now returns StripeProvider (PayKitProvider with capabilities) directly instead of config with adapter. Lazy getStripe() client initialization; removes actions from normalized webhook events; adds subscription schedule, test-clock, and webhook-endpoint methods. Tests refactored with hoisted SDK mocks.
Polar Provider Direct Implementation
packages/polar/src/polar-provider.ts, packages/polar/src/__tests__/polar-provider.test.ts
polar() returns PolarProvider directly. Introduces webhook normalization from generic PolarWebhookData; new subscription lifecycle methods with normalized outputs; cached getPolar() client; parseWebhook() with SDK-backed normalization and fallback to raw events; webhook-endpoint and customer-portal support. Tests verify endpoint creation and updates.
Core Test Utilities & Mocking
packages/paykit/src/api/__tests__/define-route.test.ts, packages/paykit/src/api/__tests__/methods.test.ts, packages/paykit/src/core/__tests__/context.test.ts, packages/paykit/src/customer/__tests__/customer.service.test.ts
Test fixtures refactored to use PayKitProvider-shaped objects with capabilities and id/name directly; createProviderMock() factory introduced for test mocks; removed createAdapter() from all test setups.
E2E Config & Harness Types
e2e/paykit.config.ts, e2e/test-utils/harness/types.ts, e2e/test-utils/env.ts
New paykit.config.ts exports configured PayKit instance based on provider selection; harness types updated to use createProvider() returning PayKitProvider; new capability flags invoiceWebhooks and repeatedHostedCheckout; E2E_POLAR_CHECKOUT_MODE enum added.
E2E Hub & Test Setup
e2e/test-utils/hub.ts, e2e/test-utils/setup.ts
Provider-agnostic extractProviderCustomerId() replaces Stripe-specific extractor; test setup uses harness.createProvider() directly; new waitForPlanPresent() polling helper replaces webhook-based waiting; webhook matching extended to webhook-id header.
Stripe & Polar E2E Harnesses
e2e/test-utils/harness/stripe.ts, e2e/test-utils/harness/polar.ts
Stripe harness declares capabilities, refactors form-filling with pressIfVisible() helper, adds screenshot on errors. Polar harness rewritten for three checkout modes (manual, direct, hosted) with client-side direct confirmation via Stripe token, full browser instrumentation for hosted flow, API slot throttling, and comprehensive UI helpers.
E2E Test Suite Capability Gating
e2e/core/*/*.test.ts, e2e/core/webhook/duplicate-webhook.test.ts
Multiple test suites now conditionally skip based on harness.capabilities flags using describe.skipIf(); tests switch from webhook-only to polling-based synchronization where needed; new test-specific polling helpers like waitForActiveSubscription() and waitForSubscriptionActive() introduced.
Documentation & Supporting Changes
apps/web/content/docs/providers/polar.mdx, .agents/skills/paykit-architecture/SKILL.md, packages/paykit/src/types/events.ts
Polar provider docs updated with webhook scope requirements and paykitjs listen setup; skill metadata adjusted; webhook event types simplified by removing actions field from normalized events.

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • getpaykit/paykit#188: Refactors the CLI listen webhook flow to stream/process deliveries over Durable Object WebSockets, directly building on this PR's webhook-endpoint-capable provider APIs.
  • getpaykit/paykit#173: Adds the webhook listen workflow that this PR's CLI refactoring targets with webhook-endpoint-capable providers.
  • getpaykit/paykit#139: Introduces the Polar payment provider adapter that this PR refactors into the new direct provider model.

Poem

🐰 From adapters we leap, to capabilities deep,
Where providers declare what they'll keep,
No more config wrapping, just types that align,
Your provider is ready—no adapter design!

✨ 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/core-cleanup

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

Caution

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

⚠️ Outside diff range comments (2)
apps/web/content/docs/providers/polar.mdx (1)

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

Update the docs to say “provider” instead of “adapter.”

This page still tells users that polar() is an adapter, but this PR removes the adapter layer and exposes providers directly. Keeping the old term here makes the new surface area look older than it is.

Also applies to: 14-14

🤖 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/content/docs/providers/polar.mdx` at line 6, Replace the outdated
term "adapter" with "provider" throughout this doc (e.g., the sentence
referencing the `@paykitjs/polar` adapter and the `polar()` adapter), updating
wording like "adapter handles all Polar API interactions" to "provider handles
all Polar API interactions" and any other occurrences (including the other
instance noted at line 14) so the docs reflect the new provider surface area.
packages/paykit/src/customer/customer.service.ts (1)

461-471: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't turn hardDeleteCustomer into a scheduled-cancellation flow.

This path now schedules cancellation for period end and also gates deleteCustomer() behind subscription-management capabilities. That can leave active remote subscriptions or an orphaned provider customer behind while the local customer is permanently deleted. Keep customer deletion separate from capability-gated subscription cleanup, and use the immediate cancel path here instead of cancelSubscriptionAtPeriodEnd.

🤖 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/customer/customer.service.ts` around lines 461 - 471, The
current flow schedules cancellations via cancelSubscriptionAtPeriodEnd and gates
deleteCustomer behind subscription-capability checks; instead, keep deletion
separate and use the immediate cancel path. Change the block that calls
assertProviderHasCapability("cancelSubscriptionsAtPeriodEnd"),
listActiveSubscriptions, and cancelSubscriptionAtPeriodEnd so that
deleteCustomer is always invoked (no capability gating), and if the provider
supports subscription cancellation attempt immediate cancellation by calling the
provider's cancelSubscription (immediate) method for each providerSubscriptionId
returned by ctx.provider.listActiveSubscriptions; remove or replace the
assertProviderHasCapability("cancelSubscriptionsAtPeriodEnd") check and do not
prevent ctx.provider.deleteCustomer({ providerCustomerId }) from running even if
subscription cancellation capability is absent.
🤖 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 @.agents/skills/paykit-architecture/SKILL.md:
- Line 3: The front-matter key "description" currently reads "Not use
automatically." which conflicts with the documented purpose; update the SKILL.md
front matter so the description accurately summarizes the file's intent (e.g.,
that this skill is for architecture and provider-integration guidance) so
discovery and invocation work correctly—locate the "description" field in the
SKILL.md front matter and replace the text with a concise, intent-aligned phrase
describing architecture/provider-integration usage.

In `@e2e/core/checkout/resubscribe-after-cancel.test.ts`:
- Around line 106-111: The test currently only waits for the webhook via
waitForWebhook but needs to wait for the subscription record to be fully
materialized as the "pro" plan before running
expectProduct/expectSingleActivePlanInGroup; replace or augment the
waitForWebhook call with the DB-polling approach used in the other checkout
tests: poll t.database (using the same timeout 120_000 and after=beforeCheckout
semantics) until the customer's subscription row shows plan "pro" (or until
expectSingleActivePlanInGroup/expectProduct would pass), then proceed. Locate
the webhook wait call and swap it for a poll that queries the subscription table
for the given customer ID and verifies the active plan is "pro" (or reuse the
helper used in other tests that waits for the active plan) to make the check
deterministic.

In `@e2e/core/checkout/subscribe-paid-checkout.test.ts`:
- Around line 113-123: The polling predicate in the rows.some check is failing
due to strict timestamp comparison (startedAt > after); remove the startedAt >
after clause so the helper no longer depends on exact timestamp precision—keep
the other checks (row.status === "active", row.planId === planId,
row.currentPeriodEndAt !== null, row.providerData !== null) and return as soon
as a matching active pro subscription is seen without comparing startedAt to
after.

In `@packages/paykit/src/cli/commands/listen.ts`:
- Around line 651-655: The CLI is hardcoding "Stripe" in progress/summary
output; replace that with the actual provider name from the loaded
webhook-endpoint-capable provider and thread it through render paths. Locate
where you call assertWebhookEndpointProvider and
provider.getWebhookEndpointAccount (and where loadRelayRuntimeContext is used)
and: 1) read a stable display name from the provider object (e.g., provider.name
or provider.displayName or provider.getDisplayName()) instead of the string
"Stripe"; 2) pass that provider name into params.devLogger.update and any
summary/render functions (including the loadRelayRuntimeContext call chain) so
all CLI messages use the real provider name; and 3) update any tests or call
sites that assumed "Stripe" to use the provider-derived name.
- Around line 233-243: The guard in assertWebhookEndpointProvider currently only
checks for method presence and must also respect the provider capability flag:
check provider.manageWebhookEndpoints and if it's explicitly false (or not
truthy) throw the same error saying the provider does not support listen; keep
the existing method checks (getWebhookEndpointAccount, ensureWebhookEndpoint,
deleteWebhookEndpoint) and only return provider as
WebhookEndpointCapableProvider when manageWebhookEndpoints is truthy and the
methods exist, updating the error message as needed to reference the capability.

In `@packages/paykit/src/product/product-sync.service.ts`:
- Around line 181-202: The per-plan sync loop must detect and fail fast on
duplicate provider product IDs before persisting conflicting mappings: after
obtaining providerResult.providerProduct.productId in the paidPlansToSync loop
(where ctx.provider.upsertSubscriptionProduct and upsertProviderProduct are
used), check against a Set of seen providerProductIds (the same collection used
to build activeProviderProductIds); if the providerProductId is already present,
throw or assert with details about the two conflicting plan IDs (e.g., current
plan.id and the previously-seen plan id) and do not call upsertProviderProduct
for the conflicting plan, otherwise add it to the Set and continue; keep
cleanupSubscriptionProducts call unchanged and pass the deduplicated
activeProviderProductIds.

In `@packages/paykit/src/testing/testing.service.ts`:
- Around line 16-20: Remove the manual capability short-circuit that throws
PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.TESTING_NOT_ENABLED) when
ctx.provider.capabilities.testClocks is falsy; instead rely on the shared helper
assertProviderHasCapability(ctx.provider, "testClocks") as the single source of
truth for capability checks. Delete the initial if-block that checks
ctx.provider.capabilities.testClocks and also remove the now-redundant
additional assertProviderHasCapability(...) calls elsewhere in this file related
to "testClocks" so the helper alone enforces and emits the capability-specific
error.

---

Outside diff comments:
In `@apps/web/content/docs/providers/polar.mdx`:
- Line 6: Replace the outdated term "adapter" with "provider" throughout this
doc (e.g., the sentence referencing the `@paykitjs/polar` adapter and the
`polar()` adapter), updating wording like "adapter handles all Polar API
interactions" to "provider handles all Polar API interactions" and any other
occurrences (including the other instance noted at line 14) so the docs reflect
the new provider surface area.

In `@packages/paykit/src/customer/customer.service.ts`:
- Around line 461-471: The current flow schedules cancellations via
cancelSubscriptionAtPeriodEnd and gates deleteCustomer behind
subscription-capability checks; instead, keep deletion separate and use the
immediate cancel path. Change the block that calls
assertProviderHasCapability("cancelSubscriptionsAtPeriodEnd"),
listActiveSubscriptions, and cancelSubscriptionAtPeriodEnd so that
deleteCustomer is always invoked (no capability gating), and if the provider
supports subscription cancellation attempt immediate cancellation by calling the
provider's cancelSubscription (immediate) method for each providerSubscriptionId
returned by ctx.provider.listActiveSubscriptions; remove or replace the
assertProviderHasCapability("cancelSubscriptionsAtPeriodEnd") check and do not
prevent ctx.provider.deleteCustomer({ providerCustomerId }) from running even if
subscription cancellation capability is absent.
🪄 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: 08011305-07e6-47e6-bc3d-9a79d72e00d2

📥 Commits

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

📒 Files selected for processing (41)
  • .agents/skills/paykit-architecture/SKILL.md
  • apps/web/content/docs/providers/polar.mdx
  • e2e/core/cancel/cancel-then-upgrade.test.ts
  • e2e/core/checkout/resubscribe-after-cancel.test.ts
  • e2e/core/checkout/subscribe-paid-checkout.test.ts
  • e2e/core/entitlements/stacked-metered.test.ts
  • e2e/core/lifecycle/subscription.test.ts
  • e2e/core/subscribe/downgrade-scheduled.test.ts
  • e2e/core/webhook/duplicate-webhook.test.ts
  • e2e/paykit.config.ts
  • e2e/test-utils/env.ts
  • e2e/test-utils/harness/polar.ts
  • e2e/test-utils/harness/stripe.ts
  • e2e/test-utils/harness/types.ts
  • e2e/test-utils/hub.ts
  • e2e/test-utils/setup.ts
  • packages/paykit/src/api/__tests__/define-route.test.ts
  • packages/paykit/src/api/__tests__/methods.test.ts
  • packages/paykit/src/cli/commands/listen.ts
  • packages/paykit/src/cli/utils/shared.ts
  • packages/paykit/src/core/__tests__/context.test.ts
  • packages/paykit/src/core/context.ts
  • packages/paykit/src/core/errors.ts
  • packages/paykit/src/customer/__tests__/customer.service.test.ts
  • packages/paykit/src/customer/customer.api.ts
  • packages/paykit/src/customer/customer.service.ts
  • packages/paykit/src/index.ts
  • packages/paykit/src/product/product-sync.service.ts
  • packages/paykit/src/providers/__tests__/capabilities.test.ts
  • packages/paykit/src/providers/capabilities.ts
  • packages/paykit/src/providers/provider.ts
  • packages/paykit/src/subscription/subscription.service.ts
  • packages/paykit/src/testing/testing.service.ts
  • packages/paykit/src/types/events.ts
  • packages/paykit/src/types/options.ts
  • packages/paykit/src/webhook/webhook.service.ts
  • packages/polar/src/__tests__/polar-provider.test.ts
  • packages/polar/src/polar-provider.ts
  • packages/stripe/src/__tests__/stripe-provider.test.ts
  • packages/stripe/src/__tests__/stripe.test.ts
  • packages/stripe/src/stripe-provider.ts
💤 Files with no reviewable changes (1)
  • packages/paykit/src/api/tests/define-route.test.ts

---
name: paykit-architecture
description: Use before architectural, API design, provider integration, billing lifecycle, database model, or product-scope decisions in PayKit.
description: Not use automatically.
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 | 🟡 Minor | ⚡ Quick win

Keep the skill description aligned with the file's actual intent.

description: Not use automatically. now conflicts with the guidance below telling readers to use this skill for architecture and provider-integration work. If this front matter drives skill discovery, this change makes the right skill harder to invoke for exactly the scenarios this file documents.

🤖 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 @.agents/skills/paykit-architecture/SKILL.md at line 3, The front-matter key
"description" currently reads "Not use automatically." which conflicts with the
documented purpose; update the SKILL.md front matter so the description
accurately summarizes the file's intent (e.g., that this skill is for
architecture and provider-integration guidance) so discovery and invocation work
correctly—locate the "description" field in the SKILL.md front matter and
replace the text with a concise, intent-aligned phrase describing
architecture/provider-integration usage.

Comment on lines +106 to +111
await waitForWebhook({
database: t.database,
eventType: "checkout.completed",
after: beforeCheckout,
timeout: 120_000,
});
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

Wait for the pro plan to become active here, not just for checkout.completed.

This webhook can be processed before the follow-up subscription state is fully materialized, so the expectProduct and expectSingleActivePlanInGroup checks below can still race on slower runs. Reusing the DB polling approach from the other checkout tests would make this path deterministic.

Proposed fix
-        await waitForWebhook({
-          database: t.database,
-          eventType: "checkout.completed",
-          after: beforeCheckout,
-          timeout: 120_000,
-        });
+        await waitForSingleActivePlanInGroup({
+          database: t.database,
+          customerId,
+          group: "base",
+          planId: "pro",
+          timeout: 120_000,
+        });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await waitForWebhook({
database: t.database,
eventType: "checkout.completed",
after: beforeCheckout,
timeout: 120_000,
});
await waitForSingleActivePlanInGroup({
database: t.database,
customerId,
group: "base",
planId: "pro",
timeout: 120_000,
});
🤖 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/core/checkout/resubscribe-after-cancel.test.ts` around lines 106 - 111,
The test currently only waits for the webhook via waitForWebhook but needs to
wait for the subscription record to be fully materialized as the "pro" plan
before running expectProduct/expectSingleActivePlanInGroup; replace or augment
the waitForWebhook call with the DB-polling approach used in the other checkout
tests: poll t.database (using the same timeout 120_000 and after=beforeCheckout
semantics) until the customer's subscription row shows plan "pro" (or until
expectSingleActivePlanInGroup/expectProduct would pass), then proceed. Locate
the webhook wait call and swap it for a poll that queries the subscription table
for the given customer ID and verifies the active plan is "pro" (or reuse the
helper used in other tests that waits for the active plan) to make the check
deterministic.

Comment on lines +113 to +123
if (
rows.some(
(row) =>
row.status === "active" &&
row.planId === planId &&
row.currentPeriodEndAt !== null &&
row.startedAt > after &&
row.providerData !== null,
)
) {
return;
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

Drop the strict startedAt > after predicate from this poll.

Line 119 makes this helper depend on timestamp precision between the test process and the persisted subscription row. If startedAt is stored at the same instant as beforeCheckout or rounded slightly earlier, this loop can burn the full 120s even though the pro subscription is already active.

Proposed fix
 async function waitForActiveSubscription(
   t: TestPayKit,
   customerId: string,
   timeout: number,
-  after: Date,
   planId: string,
 ): Promise<void> {
@@
     if (
       rows.some(
         (row) =>
           row.status === "active" &&
           row.planId === planId &&
           row.currentPeriodEndAt !== null &&
-          row.startedAt > after &&
           row.providerData !== null,
       )
     ) {
       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 `@e2e/core/checkout/subscribe-paid-checkout.test.ts` around lines 113 - 123,
The polling predicate in the rows.some check is failing due to strict timestamp
comparison (startedAt > after); remove the startedAt > after clause so the
helper no longer depends on exact timestamp precision—keep the other checks
(row.status === "active", row.planId === planId, row.currentPeriodEndAt !==
null, row.providerData !== null) and return as soon as a matching active pro
subscription is seen without comparing startedAt to after.

Comment on lines +233 to 243
function assertWebhookEndpointProvider(provider: PaymentProvider): WebhookEndpointCapableProvider {
if (
typeof provider.getTunnelAccount !== "function" ||
typeof provider.ensureTunnelWebhook !== "function" ||
typeof provider.disableTunnelWebhook !== "function"
typeof provider.getWebhookEndpointAccount !== "function" ||
typeof provider.ensureWebhookEndpoint !== "function" ||
typeof provider.deleteWebhookEndpoint !== "function"
) {
throw new Error(`Provider "${provider.name}" does not support paykitjs listen yet.`);
}

return provider as TunnelCapableProvider;
return provider as WebhookEndpointCapableProvider;
}
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 | 🟡 Minor | ⚡ Quick win

Honor manageWebhookEndpoints in the runtime guard.

This assertion only checks for method presence, so a provider can still pass listen even when it declares manageWebhookEndpoints: false. That makes the CLI drift from the new capability-driven contract.

Suggested fix
 function assertWebhookEndpointProvider(provider: PaymentProvider): WebhookEndpointCapableProvider {
   if (
+    !provider.capabilities.manageWebhookEndpoints ||
     typeof provider.getWebhookEndpointAccount !== "function" ||
     typeof provider.ensureWebhookEndpoint !== "function" ||
     typeof provider.deleteWebhookEndpoint !== "function"
   ) {
     throw new Error(`Provider "${provider.name}" does not support paykitjs listen yet.`);
🤖 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 233 - 243, The guard
in assertWebhookEndpointProvider currently only checks for method presence and
must also respect the provider capability flag: check
provider.manageWebhookEndpoints and if it's explicitly false (or not truthy)
throw the same error saying the provider does not support listen; keep the
existing method checks (getWebhookEndpointAccount, ensureWebhookEndpoint,
deleteWebhookEndpoint) and only return provider as
WebhookEndpointCapableProvider when manageWebhookEndpoints is truthy and the
methods exist, updating the error message as needed to reference the capability.

Comment on lines +651 to +655
const provider = assertWebhookEndpointProvider(config.options.provider);
const deviceToken = getOrCreateDeviceToken();

params.devLogger.update("Connecting to Stripe");
const account = await provider.getTunnelAccount();
const account = await provider.getWebhookEndpointAccount();
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 | 🟡 Minor | ⚡ Quick win

Stop hardcoding Stripe in this now-provider-agnostic flow.

loadRelayRuntimeContext() now accepts any webhook-endpoint-capable provider, but the progress/summary output in this command still says "Stripe". Polar users will get incorrect CLI messaging unless the loaded provider name is threaded through those render paths.

🤖 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 651 - 655, The CLI
is hardcoding "Stripe" in progress/summary output; replace that with the actual
provider name from the loaded webhook-endpoint-capable provider and thread it
through render paths. Locate where you call assertWebhookEndpointProvider and
provider.getWebhookEndpointAccount (and where loadRelayRuntimeContext is used)
and: 1) read a stable display name from the provider object (e.g., provider.name
or provider.displayName or provider.getDisplayName()) instead of the string
"Stripe"; 2) pass that provider name into params.devLogger.update and any
summary/render functions (including the loadRelayRuntimeContext call chain) so
all CLI messages use the real provider name; and 3) update any tests or call
sites that assumed "Stripe" to use the provider-derived name.

Comment on lines 181 to +202
if (paidPlansToSync.length > 0) {
const providerResults = await ctx.provider.syncProducts({
products: paidPlansToSync.map((p) => ({
existingProviderProduct: p.existingProviderProduct,
id: p.id,
name: p.name,
priceAmount: p.priceAmount,
priceInterval: p.priceInterval,
})),
});

const requestedIds = new Set(paidPlansToSync.map((p) => p.id));
const resultById = new Map<string, (typeof providerResults.results)[number]>();
for (const r of providerResults.results) {
if (resultById.has(r.id)) {
throw PayKitError.from(
"INTERNAL_SERVER_ERROR",
PAYKIT_ERROR_CODES.PLAN_SYNC_FAILED,
`Provider syncProducts returned duplicate mapping for id: ${r.id}`,
);
assertProviderHasCapability(ctx.provider, "subscriptionProducts");
const activeProviderProductIds: string[] = [];
for (const plan of paidPlansToSync) {
const providerResult = await ctx.provider.upsertSubscriptionProduct({
existingProviderProduct: plan.existingProviderProduct,
id: plan.id,
name: plan.name,
priceAmount: plan.priceAmount,
priceInterval: plan.priceInterval,
});
const providerProductId = providerResult.providerProduct.productId;
if (providerProductId) {
activeProviderProductIds.push(providerProductId);
}
resultById.set(r.id, r);
}
const missingIds = [...requestedIds].filter((id) => !resultById.has(id));
if (missingIds.length > 0 || resultById.size !== requestedIds.size) {
throw PayKitError.from(
"INTERNAL_SERVER_ERROR",
PAYKIT_ERROR_CODES.PLAN_SYNC_FAILED,
`Provider syncProducts returned invalid mapping: missing=[${missingIds.join(", ")}], expected=${String(requestedIds.size)}, got=${String(resultById.size)}`,
);
}

for (const [, providerResult] of resultById) {
const plan = paidPlansToSync.find((p) => p.id === providerResult.id);
if (!plan) continue;
await upsertProviderProduct(ctx.database, {
productInternalId: plan.storedProductInternalId,
providerId,
providerProduct: providerResult.providerProduct,
});
}
await ctx.provider.cleanupSubscriptionProducts?.({ activeProviderProductIds });
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

Restore duplicate provider-product safeguards in the per-plan sync path.

This refactor writes each mapping immediately, but it no longer fails fast if two plans resolve to the same provider product. In that case we silently persist conflicting mappings, and cleanupSubscriptionProducts will also treat that shared ID as valid for both plans.

Suggested guard
   if (paidPlansToSync.length > 0) {
     assertProviderHasCapability(ctx.provider, "subscriptionProducts");
     const activeProviderProductIds: string[] = [];
+    const seenProviderProductIds = new Set<string>();
     for (const plan of paidPlansToSync) {
       const providerResult = await ctx.provider.upsertSubscriptionProduct({
         existingProviderProduct: plan.existingProviderProduct,
         id: plan.id,
         name: plan.name,
         priceAmount: plan.priceAmount,
         priceInterval: plan.priceInterval,
       });
       const providerProductId = providerResult.providerProduct.productId;
       if (providerProductId) {
+        if (seenProviderProductIds.has(providerProductId)) {
+          throw PayKitError.from(
+            "INTERNAL_SERVER_ERROR",
+            PAYKIT_ERROR_CODES.PLAN_SYNC_FAILED,
+            `Provider product "${providerProductId}" was returned for multiple plans during sync`,
+          );
+        }
+        seenProviderProductIds.add(providerProductId);
         activeProviderProductIds.push(providerProductId);
       }
       await upsertProviderProduct(ctx.database, {
         productInternalId: plan.storedProductInternalId,
         providerId,
🤖 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-sync.service.ts` around lines 181 - 202,
The per-plan sync loop must detect and fail fast on duplicate provider product
IDs before persisting conflicting mappings: after obtaining
providerResult.providerProduct.productId in the paidPlansToSync loop (where
ctx.provider.upsertSubscriptionProduct and upsertProviderProduct are used),
check against a Set of seen providerProductIds (the same collection used to
build activeProviderProductIds); if the providerProductId is already present,
throw or assert with details about the two conflicting plan IDs (e.g., current
plan.id and the previously-seen plan id) and do not call upsertProviderProduct
for the conflicting plan, otherwise add it to the Set and continue; keep
cleanupSubscriptionProducts call unchanged and pass the deduplicated
activeProviderProductIds.

Comment on lines 16 to +20
if (!ctx.provider.capabilities.testClocks) {
throw PayKitError.from("BAD_REQUEST", PAYKIT_ERROR_CODES.TESTING_NOT_ENABLED);
}

assertProviderHasCapability(ctx.provider, "testClocks");
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 | 🟡 Minor | ⚡ Quick win

Don’t mask unsupported testClocks as TESTING_NOT_ENABLED.

Line 16 throws TESTING_NOT_ENABLED before assertProviderHasCapability() can run, so providers without testClocks never emit the capability-specific error this refactor introduced. If the shared helper is the new source of truth, remove the manual capability branch here and let it own the check; that also makes the extra assertions on Lines 25 and 68 unnecessary.

🤖 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/testing/testing.service.ts` around lines 16 - 20, Remove
the manual capability short-circuit that throws PayKitError.from("BAD_REQUEST",
PAYKIT_ERROR_CODES.TESTING_NOT_ENABLED) when
ctx.provider.capabilities.testClocks is falsy; instead rely on the shared helper
assertProviderHasCapability(ctx.provider, "testClocks") as the single source of
truth for capability checks. Delete the initial if-block that checks
ctx.provider.capabilities.testClocks and also remove the now-redundant
additional assertProviderHasCapability(...) calls elsewhere in this file related
to "testClocks" so the helper alone enforces and emits the capability-specific
error.

@maxktz maxktz closed this Jun 2, 2026
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