Skip to content

feat(paykit): add explicit subscription cancel API#185

Open
almeidazs wants to merge 1 commit into
getpaykit:mainfrom
almeidazs:feat/cancel-subscription-api
Open

feat(paykit): add explicit subscription cancel API#185
almeidazs wants to merge 1 commit into
getpaykit:mainfrom
almeidazs:feat/cancel-subscription-api

Conversation

@almeidazs
Copy link
Copy Markdown

@almeidazs almeidazs commented May 18, 2026

Summary

This adds an explicit cancelSubscription() API for the current customer instead of forcing cancellation flows to happen indirectly through subscribe().

The new method cancels the active paid subscription for the selected plan group, updates provider-backed billing state, and schedules the default free plan when that group has one.

Closes #63

How it works

  • adds a new client/server method: cancelSubscription({ planId })
  • resolves the requested plan to find the right subscription group
  • finds the current active subscription in that group
  • calls the provider adapter's existing cancelSubscription() hook
  • marks the current subscription as canceled at period end in PayKit's database
  • if the group has a default free plan, schedules that plan to take over automatically

Example

await paykit.cancelSubscription({
  customerId: "cus_123",
  planId: "pro",
});

Client-side:

await paykitClient.cancelSubscription({
  planId: "pro",
});

Behavior notes

  • if there is no active subscription in the selected group, PayKit returns SUBSCRIPTION_NOT_FOUND
  • if the active subscription is already canceled at period end, the call becomes a no-op
  • if the group has a default free plan, that fallback is scheduled automatically for the period end

Breaking change

No required breaking change.

This is an additive API. Existing subscription flows continue to work as before.

Tests

  • node ./node_modules/vitest/vitest.mjs run --config vitest.unit.config.ts packages/paykit/src/subscription/__tests__/subscription.service.test.ts
  • bun run typecheck --filter=paykitjs

Summary by CodeRabbit

  • New Features

    • Added subscription cancellation capability. Users can now cancel active subscriptions through a new API endpoint, with automatic provider synchronization and free plan scheduling support.
  • Tests

    • Added comprehensive test suite for subscription cancellation workflow, covering provider cancellation, plan transitions, and billing state updates.

Review Change Stack

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented May 18, 2026

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

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
paykit Skipped Skipped May 18, 2026 1:07pm

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 18, 2026

📝 Walkthrough

Walkthrough

This PR adds a subscription cancellation feature to PayKit. It introduces a cancelSubscription API method that cancels an active paid subscription, schedules the default free plan as a replacement, and synchronizes state with the payment provider within a database transaction. The change also refactors plan resolution logic into a reusable helper used by both subscription and cancellation flows.

Changes

Subscription Cancellation Feature

Layer / File(s) Summary
Cancellation type contracts
packages/paykit/src/subscription/subscription.types.ts
cancelSubscriptionBodySchema and inferred CancelSubscriptionBody define the request payload with planId; CancelSubscriptionInput extends it with customerId and optional productInternalId.
Plan resolution refactor
packages/paykit/src/subscription/subscription.service.ts
New resolveRequestedPlan helper centralizes plan/product lookup and paid/free determination; resolveDefaultFreePlanInGroup is added for free-plan scheduling; loadSubscribeContext delegates to the new helper to reduce duplication.
Cancel service implementation
packages/paykit/src/subscription/subscription.service.ts
Exported cancelPlanSubscription resolves the target plan, loads active subscription, calls the provider cancel endpoint, optionally clears scheduled subscriptions, schedules the default free plan, marks period-end cancellation, and updates subscription state within a database transaction.
API route and handler
packages/paykit/src/subscription/subscription.api.ts
New cancelSubscription PayKit method for POST /cancel-subscription accepts cancelSubscriptionBodySchema, requires a resolved customer, and forwards the request to cancelPlanSubscription.
Type refinements and public exports
packages/paykit/src/types/instance.ts
RefineServerMethodInput and RefineClientMethodInput add cancelSubscription branches to map planId and inject customerId; new public type aliases PayKitClientCancelSubscriptionInput, PayKitCancelSubscriptionInput, and PayKitCancelSubscriptionResult are exported.
Base method registration
packages/paykit/src/api/methods.ts
cancelSubscription is added to the exported baseMethods object to register it in the method composition pipeline.
Cancellation service tests
packages/paykit/src/subscription/__tests__/subscription.service.test.ts
Vitest suite with hoisted mocks for product and provider services validates provider cancel invocation, database state transitions (clearing schedules, scheduling free plan, marking cancellation), and returns no-payment result.

Sequence Diagram

sequenceDiagram
  participant Client
  participant CancelAPI as cancelSubscription<br/>(API)
  participant CancelService as cancelPlanSubscription<br/>(Service)
  participant Database
  participant Provider as Payment Provider

  Client->>CancelAPI: POST /cancel-subscription<br/>(planId, customerId)
  CancelAPI->>CancelService: cancelPlanSubscription(ctx, {customerId, planId})
  
  CancelService->>CancelService: resolveRequestedPlan(planId)
  CancelService->>Database: Load active subscription<br/>for plan group
  
  alt Active subscription exists & is paid
    CancelService->>Provider: Call provider cancel<br/>(subscriptionId, currentPeriodEndAt)
    Provider-->>CancelService: Subscription data
    
    CancelService->>Database: transaction start
    CancelService->>Database: Clear scheduled<br/>subscriptions
    CancelService->>Database: Insert scheduled free<br/>plan subscription
    CancelService->>Database: Update active subscription<br/>to mark canceled
    CancelService->>Database: Update schedule<br/>to free product
    CancelService->>Database: transaction commit
  else No active subscription<br/>or already free
    CancelService->>CancelService: Short-circuit to<br/>no-payment result
  end
  
  CancelService-->>CancelAPI: SubscribeResult<br/>{paymentUrl: null, requiredAction: null}
  CancelAPI-->>Client: {paymentUrl: null, requiredAction: null}
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • getpaykit/paykit#124: Both PRs modify packages/paykit/src/subscription/subscription.service.ts to refactor plan resolution logic that is now shared between subscription and cancellation flows.

Poem

🐰 A Plan to Say Goodbye

A rabbit once subscribed with care,
But then one day decided—there!
I'll cancel now, be free once more,
And schedule peace forevermore.
The provider agrees with glee,
Free plans and peace for all to see.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(paykit): add explicit subscription cancel API' directly and accurately summarizes the main change: introducing a new cancelSubscription API method for PayKit.
Linked Issues check ✅ Passed The PR closes issue #63 (feat(api): subscription cancel) by fully implementing the subscription cancellation feature with API method, service logic, type definitions, and comprehensive test coverage.
Out of Scope Changes check ✅ Passed All changes are directly related to implementing the subscription cancellation feature. No unrelated modifications to other functionality or dependencies were introduced.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@almeidazs almeidazs marked this pull request as ready for review May 18, 2026 13:08
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.

🧹 Nitpick comments (2)
packages/paykit/src/subscription/__tests__/subscription.service.test.ts (1)

67-225: ⚡ Quick win

Add tests for the documented cancellation edge paths.

This suite currently validates only the happy path. Please add explicit cases for: (1) no active subscription in group → SUBSCRIPTION_NOT_FOUND, and (2) already cancelAtPeriodEnd → no-op behavior, so the PR’s contract is locked by tests.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/paykit/src/subscription/__tests__/subscription.service.test.ts`
around lines 67 - 225, Tests only cover the happy path for
cancelPlanSubscription; add two new test cases: one where no active subscription
exists in the group (simulate database select/transaction returning null/empty
and assert the function throws or returns the SUBSCRIPTION_NOT_FOUND error
constant), and one where the found subscription already has
cancelAtPeriodEnd=true (mock transaction select to return a subscription with
cancelAtPeriodEnd: true and assert cancelPlanSubscription performs no provider
cancel/update/inserts and returns a no-op result). Use the same helpers/mocks as
the existing test (createSelectChain, tx transaction mock,
provider.cancelSubscription spy, and insert/update spies) and reference
cancelPlanSubscription, SUBSCRIPTION_NOT_FOUND, and the
provider.cancelSubscription mock to locate where to add these tests.
packages/paykit/src/subscription/subscription.service.ts (1)

232-249: ⚡ Quick win

Reuse this helper from the webhook fallback path too.

resolveDefaultFreePlanInGroup() now handles the "plan removed from planMap" case by falling back to stored features, but ensureScheduledDefaultPlan() still exits early when that normalized plan lookup misses. That leaves explicit cancellation and webhook reconciliation with different default-plan scheduling behavior for the same group. Consider routing ensureScheduledDefaultPlan() through this helper so the fallback rules stay aligned.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/paykit/src/subscription/subscription.service.ts` around lines 232 -
249, ensureScheduledDefaultPlan() currently bails when the normalized plan
lookup misses, causing inconsistent fallback behavior versus the webhook path;
change ensureScheduledDefaultPlan to call resolveDefaultFreePlanInGroup(ctx,
group) and use its returned internalId and planFeatures (or null) instead of
directly reading ctx.products.planMap so that the stored-plan fallback logic in
resolveDefaultFreePlanInGroup is reused and both code paths share the same
default-plan resolution rules.
🤖 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.

Nitpick comments:
In `@packages/paykit/src/subscription/__tests__/subscription.service.test.ts`:
- Around line 67-225: Tests only cover the happy path for
cancelPlanSubscription; add two new test cases: one where no active subscription
exists in the group (simulate database select/transaction returning null/empty
and assert the function throws or returns the SUBSCRIPTION_NOT_FOUND error
constant), and one where the found subscription already has
cancelAtPeriodEnd=true (mock transaction select to return a subscription with
cancelAtPeriodEnd: true and assert cancelPlanSubscription performs no provider
cancel/update/inserts and returns a no-op result). Use the same helpers/mocks as
the existing test (createSelectChain, tx transaction mock,
provider.cancelSubscription spy, and insert/update spies) and reference
cancelPlanSubscription, SUBSCRIPTION_NOT_FOUND, and the
provider.cancelSubscription mock to locate where to add these tests.

In `@packages/paykit/src/subscription/subscription.service.ts`:
- Around line 232-249: ensureScheduledDefaultPlan() currently bails when the
normalized plan lookup misses, causing inconsistent fallback behavior versus the
webhook path; change ensureScheduledDefaultPlan to call
resolveDefaultFreePlanInGroup(ctx, group) and use its returned internalId and
planFeatures (or null) instead of directly reading ctx.products.planMap so that
the stored-plan fallback logic in resolveDefaultFreePlanInGroup is reused and
both code paths share the same default-plan resolution rules.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 0724eff4-54c2-4d5a-bdb3-64a0251eef0a

📥 Commits

Reviewing files that changed from the base of the PR and between 55a0bda and 1d6e7d1.

📒 Files selected for processing (6)
  • packages/paykit/src/api/methods.ts
  • packages/paykit/src/subscription/__tests__/subscription.service.test.ts
  • packages/paykit/src/subscription/subscription.api.ts
  • packages/paykit/src/subscription/subscription.service.ts
  • packages/paykit/src/subscription/subscription.types.ts
  • packages/paykit/src/types/instance.ts

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.

feat(api): subscription cancel

1 participant