Skip to content

feat: improve subscription management#1894

Merged
aalemayhu merged 7 commits intomainfrom
feat/server-improvemnts-07.02.2026
Feb 7, 2026
Merged

feat: improve subscription management#1894
aalemayhu merged 7 commits intomainfrom
feat/server-improvemnts-07.02.2026

Conversation

@aalemayhu
Copy link
Copy Markdown
Contributor

@aalemayhu aalemayhu commented Feb 7, 2026

This addresses most of the manual cases I recieve on a weekly basis:

  • Subscription still active after deleting account
  • Issues with subscription check

Summary by CodeRabbit

  • New Features

    • Added ability to check subscription status via new endpoint.
    • Enhanced account deletion to automatically cancel active subscriptions.
  • Chores

    • Improved environment configuration with separate production and local development origin settings.

Signed-off-by: Alexander Alemayhu <alexander@alemayhu.com>
Signed-off-by: Alexander Alemayhu <alexander@alemayhu.com>
Signed-off-by: Alexander Alemayhu <alexander@alemayhu.com>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Feb 7, 2026

Warning

Rate limit exceeded

@aalemayhu has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 9 minutes and 51 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📝 Walkthrough

Walkthrough

The changes introduce subscription management functionality including a new SubscriptionService for handling Stripe subscription cancellations, integrate subscription cleanup into the user deletion workflow, add an API endpoint to check subscription status, and adjust environment-specific configurations for allowed origins and cleanup scheduling.

Changes

Cohort / File(s) Summary
Subscription Management Core
src/services/SubscriptionService.ts
New service providing subscription cancellation, retrieval, and deactivation with Stripe integration and robust error handling per-subscription to prevent overall failures.
Controller Integration
src/controllers/StripeController/StripeController.ts, src/controllers/UsersControllers.ts
Added checkSubscriptionStatus and cancelUserSubscriptions methods; integrated subscription cancellation into deleteAccount workflow before user removal.
API Routes
src/routes/WebhookRouter.ts
New GET endpoint /api/stripe/subscription-status with Swagger documentation to check authentication and subscription status.
Environment Configuration
src/lib/constants.ts, src/data_layer/index.ts
Refactored ALLOWED_ORIGINS into dynamic PRODUCTION_ORIGINS and LOCAL_DEV_ORIGINS arrays; added LOCAL_DEV guard to production cleanup scheduling logic.

Sequence Diagram(s)

sequenceDiagram
    participant User as User
    participant Controller as UsersController
    participant SubService as SubscriptionService
    participant Stripe as Stripe API
    participant DB as Database

    User->>Controller: DELETE /users/{id}
    Controller->>DB: Fetch user by owner ID
    Controller->>SubService: cancelUserSubscriptions(user.email)
    SubService->>DB: Query active subscriptions for email
    SubService->>Stripe: Cancel subscription (per subscription)
    Stripe-->>SubService: Confirmation
    SubService->>DB: Mark subscription inactive
    SubService-->>Controller: Cleanup complete
    Controller->>DB: Delete user account
    DB-->>Controller: User deleted
    Controller-->>User: 200 OK
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 With whiskers twitched and paws held high,
We cancel subscriptions—no goodbye!
Origins align, the config's clean,
Stripe talks to Service, a seamless scene.
Data flows onward, deletion's neat,
Cleanup complete from head to feet! 🌟

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: improve subscription management' is partially related to the changeset. It captures a major aspect (subscription management improvements) but is somewhat broad and doesn't highlight the primary changes: adding subscription status checks, canceling subscriptions on account deletion, and restricting localhost access based on environment variables.

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

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/server-improvemnts-07.02.2026

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.

Copy link
Copy Markdown
Contributor

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

Caution

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

⚠️ Outside diff range comments (2)
src/lib/constants.ts (1)

5-28: ⚠️ Potential issue | 🟡 Minor

Trailing slashes in origin URLs may cause CORS mismatches.

Lines 13 and 15 have trailing slashes (https://notion.2anki.net/, https://templates.2anki.net/). Browser Origin headers never include a trailing slash, so these entries will never match a CORS check. This appears to be a pre-existing issue, but worth fixing while you're touching this block.

The conditional composition via LOCAL_DEV looks good overall.

Proposed fix
-  'https://notion.2anki.net/',
+  'https://notion.2anki.net',
   'https://staging.2anki.net',
-  'https://templates.2anki.net/',
+  'https://templates.2anki.net',
src/controllers/UsersControllers.ts (1)

251-256: ⚠️ Potential issue | 🟡 Minor

Pre-existing: guard condition may allow execution with a falsy owner.

The condition !owner && req.body.confirmed === true on line 253 only guards against the case where owner is falsy and confirmed is true. If owner is falsy and confirmed is not true, execution falls through to getUserById(owner) with a falsy value. Consider whether the guard should be !owner alone (or !owner || !req.body.confirmed).

This is pre-existing, but the new code on line 259 now relies on owner being valid, making this gap slightly more impactful.

🤖 Fix all issues with AI agents
In `@src/controllers/StripeController/StripeController.ts`:
- Around line 96-98: Remove the dead wrapper method
StripeController.cancelUserSubscriptions: locate the async method
cancelUserSubscriptions in class StripeController and delete it, ensuring no
other code references it (replace any calls with direct
SubscriptionService.cancelUserSubscriptions if found); also remove any related
imports or tests that only exist for this method and run the test/compile to
confirm no remaining references.

In `@src/services/SubscriptionService.ts`:
- Around line 43-50: The current sequence calls stripe.subscriptions.cancel(...)
then updates the local record via database('subscriptions').where({ id:
subscription.id }).update({ active: false }), which can leave the DB active if
the update fails; fix by making the operation atomic: update the local record
first (set active=false or set a pending_cancellation flag) using
database('subscriptions').where({ id: subscription.id }).update(...), then call
stripe.subscriptions.cancel(stripeSubscription.id); if the Stripe call fails,
either revert the DB change or persist a pending_cancellation marker and enqueue
a retry/job to re-attempt cancellation, or alternatively implement a short
targeted retry loop around the DB update when stripe.subscriptions.cancel
succeeds so the two states cannot diverge.
- Line 19: The log in SubscriptionService currently prints the raw userEmail
(console.log(`Found ${subscriptions.length} active subscriptions for user
${userEmail}`)), which is PII; change it to avoid logging the plain email by
either using an opaque identifier (e.g., userId if available) or a
redacted/hashed form of userEmail (implement a small helper like redactEmail or
hashEmail and call it here), and update the message to reference subscriptions
and the safe identifier (e.g., `Found ${subscriptions.length} active
subscriptions for user <redacted>` or using userId) so no raw email is emitted
from the method in SubscriptionService.
- Around line 78-84: Make deactivateSubscription a static method to match the
rest of the API: change the declaration to "static async
deactivateSubscription(subscriptionId: number): Promise<void>" in
SubscriptionService, ensure it doesn't reference instance state (no use of
this), keep the same internals (calling getDatabase() and updating
'subscriptions'), and update any callers to call
SubscriptionService.deactivateSubscription(...) rather than instantiating the
class.
🧹 Nitpick comments (1)
src/controllers/StripeController/StripeController.ts (1)

62-70: Service instantiation is duplicated from getSuccessfulCheckout.

Lines 62-70 are nearly identical to lines 22-30. Consider extracting a private helper (e.g., getAuthService()) to reduce duplication and keep the two methods in sync.

Example extraction
+  private getServices() {
+    const database = getDatabase();
+    const emailService = useDefaultEmailService();
+    const userRepository = new UsersRepository(database);
+    const usersService = new UsersService(userRepository, emailService);
+    const tokenRepository = new TokenRepository(database);
+    const authService = new AuthenticationService(tokenRepository, userRepository);
+    return { database, usersService, authService };
+  }

Comment on lines +96 to +98
async cancelUserSubscriptions(userEmail: string): Promise<void> {
return SubscriptionService.cancelUserSubscriptions(userEmail);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

rg -n 'cancelUserSubscriptions' --type=ts -C2

Repository: 2anki/server

Length of output: 1266


Remove dead code: StripeController.cancelUserSubscriptions is an unused wrapper.

The method only proxies to SubscriptionService.cancelUserSubscriptions, but the only caller in the codebase (UsersControllers.ts line 265) calls SubscriptionService directly. This wrapper adds no value and should be removed.

🤖 Prompt for AI Agents
In `@src/controllers/StripeController/StripeController.ts` around lines 96 - 98,
Remove the dead wrapper method StripeController.cancelUserSubscriptions: locate
the async method cancelUserSubscriptions in class StripeController and delete
it, ensuring no other code references it (replace any calls with direct
SubscriptionService.cancelUserSubscriptions if found); also remove any related
imports or tests that only exist for this method and run the test/compile to
confirm no remaining references.

})
.andWhere({ active: true });

console.log(`Found ${subscriptions.length} active subscriptions for user ${userEmail}`);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Logging user email is a PII/compliance risk.

console.log on line 19 includes the raw user email. This can end up in log aggregation systems and may violate GDPR/CCPA requirements. Redact or omit the email, or use an opaque user identifier instead.

Proposed fix
-      console.log(`Found ${subscriptions.length} active subscriptions for user ${userEmail}`);
+      console.log(`Found ${subscriptions.length} active subscriptions for user`);
📝 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
console.log(`Found ${subscriptions.length} active subscriptions for user ${userEmail}`);
console.log(`Found ${subscriptions.length} active subscriptions for user`);
🤖 Prompt for AI Agents
In `@src/services/SubscriptionService.ts` at line 19, The log in
SubscriptionService currently prints the raw userEmail (console.log(`Found
${subscriptions.length} active subscriptions for user ${userEmail}`)), which is
PII; change it to avoid logging the plain email by either using an opaque
identifier (e.g., userId if available) or a redacted/hashed form of userEmail
(implement a small helper like redactEmail or hashEmail and call it here), and
update the message to reference subscriptions and the safe identifier (e.g.,
`Found ${subscriptions.length} active subscriptions for user <redacted>` or
using userId) so no raw email is emitted from the method in SubscriptionService.

Comment on lines +43 to +50
if (stripeSubscription?.id) {
console.log(`Cancelling Stripe subscription ${stripeSubscription.id}`);
await stripe.subscriptions.cancel(stripeSubscription.id);

// Update local database to mark as inactive
await database('subscriptions')
.where({ id: subscription.id })
.update({ active: false });
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Stripe cancellation and DB update are not atomic — local record stays active if the DB update fails.

If stripe.subscriptions.cancel succeeds on line 45 but the DB update on lines 48-50 throws, the outer catch (line 56) logs and continues, leaving the local record marked active while Stripe has already cancelled the subscription. Consider swapping the order (mark inactive first, then cancel in Stripe) or adding a targeted retry for the DB update.

🤖 Prompt for AI Agents
In `@src/services/SubscriptionService.ts` around lines 43 - 50, The current
sequence calls stripe.subscriptions.cancel(...) then updates the local record
via database('subscriptions').where({ id: subscription.id }).update({ active:
false }), which can leave the DB active if the update fails; fix by making the
operation atomic: update the local record first (set active=false or set a
pending_cancellation flag) using database('subscriptions').where({ id:
subscription.id }).update(...), then call
stripe.subscriptions.cancel(stripeSubscription.id); if the Stripe call fails,
either revert the DB change or persist a pending_cancellation marker and enqueue
a retry/job to re-attempt cancellation, or alternatively implement a short
targeted retry loop around the DB update when stripe.subscriptions.cancel
succeeds so the two states cannot diverge.

Comment on lines +78 to +84
async deactivateSubscription(subscriptionId: number): Promise<void> {
const database = getDatabase();

await database('subscriptions')
.where({ id: subscriptionId })
.update({ active: false });
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

deactivateSubscription is an instance method while the rest are static — inconsistent API.

cancelUserSubscriptions and getUserActiveSubscriptions are static, but deactivateSubscription is an instance method. Callers would need to instantiate SubscriptionService only for this one method, which is awkward. Make it static for consistency.

Proposed fix
-  async deactivateSubscription(subscriptionId: number): Promise<void> {
+  static async deactivateSubscription(subscriptionId: number): Promise<void> {
📝 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
async deactivateSubscription(subscriptionId: number): Promise<void> {
const database = getDatabase();
await database('subscriptions')
.where({ id: subscriptionId })
.update({ active: false });
}
static async deactivateSubscription(subscriptionId: number): Promise<void> {
const database = getDatabase();
await database('subscriptions')
.where({ id: subscriptionId })
.update({ active: false });
}
🤖 Prompt for AI Agents
In `@src/services/SubscriptionService.ts` around lines 78 - 84, Make
deactivateSubscription a static method to match the rest of the API: change the
declaration to "static async deactivateSubscription(subscriptionId: number):
Promise<void>" in SubscriptionService, ensure it doesn't reference instance
state (no use of this), keep the same internals (calling getDatabase() and
updating 'subscriptions'), and update any callers to call
SubscriptionService.deactivateSubscription(...) rather than instantiating the
class.

Signed-off-by: Alexander Alemayhu <alexander@alemayhu.com>
Will be used to purge old accounts

Signed-off-by: Alexander Alemayhu <alexander@alemayhu.com>
Signed-off-by: Alexander Alemayhu <alexander@alemayhu.com>
Signed-off-by: Alexander Alemayhu <alexander@alemayhu.com>
@sonarqubecloud
Copy link
Copy Markdown

sonarqubecloud bot commented Feb 7, 2026

@aalemayhu aalemayhu merged commit b97c691 into main Feb 7, 2026
7 checks passed
@aalemayhu aalemayhu deleted the feat/server-improvemnts-07.02.2026 branch February 7, 2026 14:52
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