Skip to content

feat(provider): implement a comprehensive subscription lifecycle#20

Draft
piyushsinghgaur1 wants to merge 6 commits intomasterfrom
feat/recurring-payment
Draft

feat(provider): implement a comprehensive subscription lifecycle#20
piyushsinghgaur1 wants to merge 6 commits intomasterfrom
feat/recurring-payment

Conversation

@piyushsinghgaur1
Copy link
Copy Markdown

@piyushsinghgaur1 piyushsinghgaur1 commented Mar 25, 2026

This pull request introduces full recurring subscription lifecycle support to the billing library, with a focus on Chargebee integration. It adds a provider-agnostic subscription interface, implements the Chargebee subscription API using the new interface, and enables seamless injection of subscription services. The changes also include a new adapter for mapping Chargebee subscription objects and extend configuration options for Chargebee. Below are the most important changes:

Subscription Lifecycle Support and Provider Injection

  • Added ISubscriptionService to the binding keys in src/keys.ts, allowing controllers and services to inject subscription capabilities independently of one-time billing. Introduced a SubscriptionProvider binding key for this purpose. [1] [2]
  • Updated the billing provider's value() method to return the result of getProvider(), streamlining service instantiation.

Chargebee Subscription Implementation

  • Implemented the complete ISubscriptionService interface in ChargeBeeService, including methods for creating products, prices, subscriptions, updating/canceling/pausing/resuming subscriptions, and retrieving invoice price details. These methods map to Chargebee's Items API v2. [1] [2] [3]
  • Added a new ChargebeeSubscriptionAdapter in src/providers/sdk/chargebee/adapter/subscription.adapter.ts for mapping between Chargebee subscription objects and the library's provider-agnostic types.
  • Exported the new subscription adapter in both Chargebee and Stripe adapter index files to support provider-agnostic usage.

Type and Interface Enhancements

  • Extended the IChargeBeeService interface to combine both one-time billing and recurring subscription management, with detailed JSDoc mapping library types to Chargebee's API. [1] [2]

Configuration Improvements

  • Added a new ChargeBeeConfig interface with optional overrides for item family, pricing model, cancellation behavior, and cancel reason code, providing more flexibility for Chargebee integration. [1] [2]

Testing and Build Process

  • Updated the test script in package.json to run tests using lb-mocha instead of a placeholder, ensuring automated test execution.

…tripe

Implement a comprehensive subscription lifecycle within loopback4-billing to support
automatedrecurring billing, including subscription creation, upgrades and downgrades,
renewals,cancellations, and proration, ensuring consistency and scalability for SaaS monetization
@sonarqube-agent
Copy link
Copy Markdown

sonarqube-agent bot commented Mar 25, 2026

SonarQube Remediation Agent

SonarQube found 3 issues in this PR that the agent can fix for you. Est. time saved: ~16 min

3 issues found
  • 🔴 Prefer return value over return Promise.resolve(value).stripe.service.ts:414
  • 🔴 Function has a complexity of 11 which is greater than 10 authorized.stripe.service.ts:203
  • 🟡 Handle this exception or don't catch it at all.stripe.service.ts:418
  • Run Remediation Agent
    Select the checkbox above to enable this action.

View Project in SonarCloud

…tripe

Implement a comprehensive subscription lifecycle within loopback4-billing to support
automatedrecurring billing, including subscription creation, upgrades and downgrades,
renewals,cancellations, and proration, ensuring consistency and scalability for SaaS monetization

GH-0
@piyushsinghgaur1 piyushsinghgaur1 self-assigned this Mar 25, 2026
@piyushsinghgaur1 piyushsinghgaur1 added the enhancement New feature or request label Mar 25, 2026
rohit-wadhwa

This comment was marked as outdated.

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Implements provider-agnostic recurring subscription lifecycle support in the billing library, adding subscription types/interfaces and wiring up Chargebee and Stripe SDK services/adapters so consumers can inject subscription operations separately from one-time billing.

Changes:

  • Added subscription domain types and a new ISubscriptionService interface to standardize recurring subscription operations across providers.
  • Implemented subscription lifecycle methods for Stripe and Chargebee, including new subscription adapters and provider config extensions.
  • Added unit tests for Stripe/Chargebee subscription flows and updated the test script to run compiled tests via lb-mocha.

Reviewed changes

Copilot reviewed 16 out of 17 changed files in this pull request and generated 9 comments.

Show a summary per file
File Description
src/types.ts Adds provider-agnostic subscription types/enums and ISubscriptionService.
src/providers/sdk/stripe/type/stripe-config.type.ts Introduces Stripe subscription-related configuration.
src/providers/sdk/stripe/type/index.ts Extends Stripe service interface to include ISubscriptionService and re-exports config.
src/providers/sdk/stripe/stripe.service.ts Implements subscription lifecycle methods in StripeService and adds subscription adapter usage.
src/providers/sdk/stripe/adapter/subscription.adapter.ts Adds adapter mapping Stripe subscriptions to normalized subscription result types.
src/providers/sdk/stripe/adapter/index.ts Exports the new Stripe subscription adapter.
src/providers/sdk/chargebee/type/index.ts Extends Chargebee service interface to include ISubscriptionService and re-exports config.
src/providers/sdk/chargebee/type/chargebee-config.type.ts Adds Chargebee subscription-related configuration overrides.
src/providers/sdk/chargebee/charge-bee.service.ts Implements subscription lifecycle methods in ChargeBeeService and adds subscription adapter usage.
src/providers/sdk/chargebee/adapter/subscription.adapter.ts Adds adapter mapping Chargebee subscriptions to normalized subscription result types.
src/providers/sdk/chargebee/adapter/index.ts Exports the new Chargebee subscription adapter.
src/providers/billing.provider.ts Simplifies provider value() to return getProvider() directly.
src/keys.ts Adds SubscriptionProvider binding key for injecting subscription operations.
src/tests/unit/stripe-subscription.service.unit.ts Adds unit tests for Stripe subscription lifecycle behavior.
src/tests/unit/chargebee-subscription.service.unit.ts Adds unit tests for Chargebee subscription lifecycle behavior.
package.json Updates test script to run built test files via lb-mocha.
package-lock.json Lockfile updates from dependency/metadata changes.
Comments suppressed due to low confidence (1)

src/providers/sdk/stripe/stripe.service.ts:47

  • StripeBindings.config is marked {optional: true}, but the constructor dereferences stripeConfig.secretKey unconditionally. If the binding isn’t present in a consuming app, this will throw at startup. Either make the injection non-optional, or accept StripeConfig | undefined and throw a clear configuration error (or apply a safe default) before constructing the Stripe SDK client.
  constructor(
    @inject(StripeBindings.config, {optional: true})
    private readonly stripeConfig: StripeConfig,
  ) {
    this.stripe = new Stripe(stripeConfig.secretKey ?? '', {
      apiVersion: '2024-09-30.acacia', // Update to latest API version as needed
    });

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +317 to +324
const created = await this.stripe.subscriptions.create({
customer: subscription.customerId,
items: [{price: subscription.priceRefId}],
collection_method: subscription.collectionMethod,
days_until_due: subscription.daysUntilDue,
payment_behavior: (this.stripeConfig.defaultPaymentBehavior ??
'default_incomplete') as Stripe.SubscriptionCreateParams.PaymentBehavior,
});
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

createSubscription always includes days_until_due, even when collection_method is charge_automatically. Stripe rejects days_until_due for non-send_invoice subscriptions. Only include days_until_due when collectionMethod is SEND_INVOICE (and ideally validate it’s provided in that case).

Suggested change
const created = await this.stripe.subscriptions.create({
customer: subscription.customerId,
items: [{price: subscription.priceRefId}],
collection_method: subscription.collectionMethod,
days_until_due: subscription.daysUntilDue,
payment_behavior: (this.stripeConfig.defaultPaymentBehavior ??
'default_incomplete') as Stripe.SubscriptionCreateParams.PaymentBehavior,
});
const params: Stripe.SubscriptionCreateParams = {
customer: subscription.customerId,
items: [{price: subscription.priceRefId}],
collection_method: subscription.collectionMethod,
payment_behavior: (this.stripeConfig.defaultPaymentBehavior ??
'default_incomplete') as Stripe.SubscriptionCreateParams.PaymentBehavior,
};
if (subscription.collectionMethod === CollectionMethod.SEND_INVOICE) {
if (subscription.daysUntilDue == null) {
throw new Error(
'daysUntilDue must be provided when collectionMethod is SEND_INVOICE',
);
}
params.days_until_due = subscription.daysUntilDue;
}
const created = await this.stripe.subscriptions.create(params);

Copilot uses AI. Check for mistakes.
Comment on lines +360 to +381
if (existing.status === 'incomplete') {
// Cancel the incomplete subscription and create a fresh one so the
// customer gets a new payment confirmation link.
await this.stripe.subscriptions.cancel(subscriptionId);
const newId = await this.createSubscription({
customerId: existing.customer as string,
priceRefId: updates.priceRefId ?? '',
collectionMethod: CollectionMethod.CHARGE_AUTOMATICALLY,
});
return {
id: newId,
status: 'incomplete',
customerId: existing.customer as string,
};
}

const priceItemId = existing.items.data[0].id;
const updated = await this.stripe.subscriptions.update(subscriptionId, {
proration_behavior:
updates.prorationBehavior as Stripe.SubscriptionUpdateParams.ProrationBehavior,
items: [{id: priceItemId, price: updates.priceRefId}],
});
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

updateSubscription uses updates.priceRefId even though it’s optional in TSubscriptionUpdate. In the incomplete path it falls back to an empty string, and in the active path it passes price: undefined; both will cause Stripe API errors. Require priceRefId for updates (throw a validation error if missing) or support “no price change” by omitting the items update when it isn’t provided.

Copilot uses AI. Check for mistakes.
Comment on lines +1 to +13
export interface StripeConfig {
secretKey: string;
/**
* Controls how Stripe handles payment during subscription creation.
* Defaults to `'default_incomplete'` (SCA-compliant: subscription starts
* incomplete until the first payment is confirmed).
*
* Set to `'allow_incomplete'` or `'error_if_incomplete'` to change the
* behaviour for your integration.
*
* @see https://stripe.com/docs/api/subscriptions/create#create_subscription-payment_behavior
*/
defaultPaymentBehavior?: string;
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

defaultPaymentBehavior is typed as a plain string, which allows invalid values to compile and then fail at runtime. Prefer typing it to Stripe’s allowed union (e.g. Stripe.SubscriptionCreateParams.PaymentBehavior) or an explicit string-literal union matching Stripe’s documented values.

Copilot uses AI. Check for mistakes.
Comment on lines +165 to +171
expect(result.unitAmount).to.equal(4999);
expect(result.product).to.equal('enterprise-plan');
expect(result.recurring?.interval).to.equal(RecurringInterval.MONTH);
expect(result.recurring?.intervalCount).to.equal(1);
expect(result.active).to.be.true();
});

Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

These tests use Chai assertions like expect(x).to.be.true() / expect(x).to.be.false(). In Chai, true/false are assertion properties (no call), so calling them typically throws ... is not a function at runtime. Use expect(x).to.be.true / expect(x).to.be.false or expect(x).to.equal(true/false) throughout this file.

Copilot uses AI. Check for mistakes.
customerId: existing.customer as string,
};
}

Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

existing.items.data[0] is accessed without checking that the subscription has at least one item. If the subscription has no items (or the first item is missing), this will throw before calling Stripe. Consider validating existing.items.data.length > 0 and returning a clear error instead of an unhandled exception.

Suggested change
if (
!existing.items ||
!existing.items.data ||
existing.items.data.length === 0 ||
!existing.items.data[0] ||
!existing.items.data[0].id
) {
throw new Error(
'[StripeService] updateSubscription: subscription has no items to update',
);
}

Copilot uses AI. Check for mistakes.
Comment on lines 35 to +47
constructor(
@inject(ChargeBeeBindings.config, {optional: true})
private readonly chargeBeeConfig: ChargeBeeConfig,
) {
// config initialise
chargebee.configure({
site: chargeBeeConfig.site,
api_key: chargeBeeConfig.apiKey,
});
// Only configure the global chargebee singleton when a valid site is
// provided. This prevents a second instantiation with empty config
// (e.g. SDKProvider vs SubscriptionProvider) from resetting the site.
if (chargeBeeConfig?.site) {
chargebee.configure({
site: chargeBeeConfig.site,
api_key: chargeBeeConfig.apiKey,
});
}
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

ChargeBeeBindings.config is injected with {optional: true}, but the class later dereferences this.chargeBeeConfig.* in multiple subscription methods (e.g. defaultPricingModel/cancelAtEndOfTerm). If config isn’t bound, this will throw at runtime. Either make the injection non-optional, or accept ChargeBeeConfig | undefined and fail fast with a clear configuration error before any provider calls.

Copilot uses AI. Check for mistakes.
Comment on lines +472 to +480
const result = await chargebee.subscription
.update_for_items(subscriptionId, {
subscription_items: updates.priceRefId
? [{item_price_id: updates.priceRefId}]
: [],
discounts: [], // Required by Chargebee SDK type
// When prorationBehavior is 'none', pass prorate:false to suppress credit notes
prorate: updates.prorationBehavior !== 'none',
})
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

updateSubscription sends subscription_items: [] when updates.priceRefId is omitted. That can unintentionally remove all items (or be rejected by Chargebee), even if the caller only wanted to change proration behavior. Consider requiring priceRefId for updates (validate and throw if missing), or fetch the current item(s) and only update proration-related fields when no new price is provided.

Suggested change
const result = await chargebee.subscription
.update_for_items(subscriptionId, {
subscription_items: updates.priceRefId
? [{item_price_id: updates.priceRefId}]
: [],
discounts: [], // Required by Chargebee SDK type
// When prorationBehavior is 'none', pass prorate:false to suppress credit notes
prorate: updates.prorationBehavior !== 'none',
})
const updateParams: {
subscription_items?: {item_price_id: string}[];
discounts: unknown[];
prorate: boolean;
} = {
discounts: [], // Required by Chargebee SDK type
// When prorationBehavior is 'none', pass prorate:false to suppress credit notes
prorate: updates.prorationBehavior !== 'none',
};
if (updates.priceRefId) {
updateParams.subscription_items = [
{item_price_id: updates.priceRefId},
];
}
const result = await chargebee.subscription
.update_for_items(subscriptionId, updateParams)

Copilot uses AI. Check for mistakes.
Comment on lines +583 to +585
.collect_payment(invoiceId, {
payment_source_id: undefined,
})
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

sendPaymentLink passes { payment_source_id: undefined }. Elsewhere in this file you note that undefined values can still serialize the key, which can change API behavior or trigger validation errors. Prefer omitting payment_source_id entirely (conditional spread) so the request matches the intended “no payment source” semantics.

Suggested change
.collect_payment(invoiceId, {
payment_source_id: undefined,
})
.collect_payment(invoiceId, {})

Copilot uses AI. Check for mistakes.
Comment on lines +184 to +188
expect(result.product).to.equal('prod_enterprise_123');
expect(result.recurring?.interval).to.equal(RecurringInterval.MONTH);
expect(result.recurring?.intervalCount).to.equal(1);
expect(result.active).to.be.true();
});
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

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

These tests use Chai assertions like expect(x).to.be.true() / expect(x).to.be.false(). In Chai, true/false are assertion properties (no call), so calling them typically throws ... is not a function at runtime. Use expect(x).to.be.true / expect(x).to.be.false or expect(x).to.equal(true/false) throughout this file.

Copilot uses AI. Check for mistakes.
* other gateway implementation) here so controllers and services can inject
* subscription capabilities independently of one-time billing.
*/
export const SubscriptionProvider = BindingKey.create<ISubscriptionService>(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

what is the need of it

*/
export interface TProduct {
name: string;
description?: string;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

does product only have these field in it?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Yes, both Stripe and Chargebee minimally require a name and the description and metadata fields cover the most common extension points

*/
export interface TSubscriptionUpdate {
/** New price / plan reference ID. */
priceRefId?: string;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

does subscription update only allow these two fields to update?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

collectionMethod and daysUntilDue have now been added to

items: [{price: subscription.priceRefId}],
collection_method: subscription.collectionMethod,
days_until_due: subscription.daysUntilDue,
payment_behavior: (this.stripeConfig.defaultPaymentBehavior ??
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

why payment behaviour is coming from config, it should be coming from arguement. in this way, are not we goinf to set the behaviour for complete aplication at once. what if we want to create the two subscription with different behaviour

… handling

address PR review comments for type safety and error handling

GH-21
fix sonar issue

GH-0
} catch (err) {
// Invoice cleanup is best-effort after cancellation.
// Surface as a structured error so callers and APM tools can observe it.
throw Object.assign(
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The subscription is cancelled on the first line of this method. If the subsequent invoice cleanup fails, the catch block now throws — meaning the caller receives an error with no way to know the cancellation already succeeded. A caller that retries on error will attempt to cancel an already-cancelled subscription in Stripe.

The comment above the try block still says "best-effort" but the code now does the opposite.

Either keep it truly best-effort:

} catch (err) {
  // The subscription is already cancelled at this point.
  // Invoice cleanup is best-effort — do not propagate so the caller
  // is not misled into thinking the cancellation itself failed.
  process.emitWarning(
    `[StripeService] cancelSubscription: invoice cleanup failed for ${subscriptionId}: ${String(err)}`,
  );
}

Or if throwing is intentional, update the method JSDoc and the comment to make clear that an error here means "cancelled OK, cleanup failed" — so callers can distinguish it from a genuine cancel failure.

};
}

const priceItemId = existing.items.data[0].id;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

existing.items.data[0] is accessed without checking that the array is non-empty. A subscription with no items — edge case for a cancelled or incomplete Stripe subscription — throws an unhandled TypeError here with no useful context for the caller.

const items = existing.items?.data;
if (!items || items.length === 0) {
  throw new Error(
    `Subscription ${subscriptionId} has no items and cannot be updated`,
  );
}
const priceItemId = items[0].id;

A clear, intentional error is always better than a runtime TypeError with no context.

paymentSource: PaymentSourceAdapter;
chargebeeSubscriptionAdapter: ChargebeeSubscriptionAdapter;
constructor(
@inject(ChargeBeeBindings.config, {optional: true})
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The constructor injects ChargeBeeBindings.config with {optional: true}, meaning chargeBeeConfig can be undefined at runtime. However, properties like chargeBeeConfig.defaultItemFamilyId, chargeBeeConfig.defaultItemPriceId, and chargeBeeConfig.site are accessed throughout the class without any null guard. If the binding is not provided, every method call will throw a misleading TypeError: Cannot read properties of undefined.

Either remove optional if the config is truly required, or add a guard in the constructor:

constructor(
  @inject(ChargeBeeBindings.config, {optional: true})
  private readonly chargeBeeConfig: ChargeBeeConfig,
) {
  if (!chargeBeeConfig) {
    throw new Error(
      'ChargeBeeConfig binding is required. Provide a value for ChargeBeeBindings.config.',
    );
  }
  // existing init ...
}

This surfaces the misconfiguration at startup rather than at the first API call, making the root cause immediately obvious.

try {
const result = await chargebee.subscription
.update_for_items(subscriptionId, {
subscription_items: updates.priceRefId
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

When updates.priceRefId is absent, subscription_items is set to an empty array [] and passed to update_for_items. Chargebee's Items API v2 interprets an empty subscription_items list as "remove all items", which silently cancels or corrupts the subscription rather than doing a no-op update.

Only include subscription_items in the payload when there is actually something to update:

const updateParams: ChargeBee.SubscriptionUpdateForItemsParams = {
  ...(updates.priceRefId && {
    subscription_items: [
      {
        item_price_id: updates.priceRefId,
        quantity: updates.quantity ?? 1,
      },
    ],
  }),
  ...(updates.trialEnd && {trial_end: Math.floor(updates.trialEnd.getTime() / 1000)}),
};

if (Object.keys(updateParams).length === 0) {
  // nothing to update — return current subscription
  const existing = await chargebee.subscription.retrieve(subscriptionId).request();
  return this.chargebeeSubscriptionAdapter.adaptToModel(existing.subscription);
}

This prevents accidental data loss when callers omit priceRefId.

*
* @see https://stripe.com/docs/api/subscriptions/create#create_subscription-payment_behavior
*/
defaultPaymentBehavior?: string;
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

defaultPaymentBehavior is typed as plain string, so nothing stops callers from passing an invalid value like 'charge-automatically' (kebab-case) that Stripe will silently reject or mishandle at runtime.

Scope the type to the values Stripe actually accepts:

import Stripe from 'stripe';

export type StripeConfig = {
  secretKey: string;
  defaultPaymentBehavior?: Stripe.SubscriptionCreateParams.PaymentBehavior;
  // ^ 'allow_incomplete' | 'default_incomplete' | 'error_if_incomplete' | 'pending_if_incomplete'
};

This gives full IDE autocompletion and a compile-time guard against typos.

* @param data - Provider-agnostic subscription creation payload.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
adaptFromModel(data: Partial<TSubscriptionCreate>): any {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

adaptFromModel returns any here (and the same pattern appears in the Stripe adapter's adaptFromModel). The callers already have the correct SDK param types from the Chargebee and Stripe type definitions, so these can be properly typed without any extra work.

Chargebee adapter:

adaptFromModel(
  subscription: TSubscriptionCreate,
): ChargeBee.SubscriptionCreateWithItemsParams {
  return {
    subscription_items: [
      {
        item_price_id: subscription.priceRefId,
        quantity: subscription.quantity ?? 1,
      },
    ],
    ...(subscription.trialPeriodDays !== undefined && {
      trial_end: Math.floor(
        Date.now() / 1000 + subscription.trialPeriodDays * 86400,
      ),
    }),
  };
}

Stripe adapter:

adaptFromModel(
  subscription: TSubscriptionCreate,
): Stripe.SubscriptionCreateParams {
  return {
    customer: subscription.customerId,
    items: [{price: subscription.priceRefId, quantity: subscription.quantity ?? 1}],
    ...(subscription.trialPeriodDays !== undefined && {
      trial_period_days: subscription.trialPeriodDays,
    }),
  };
}

Typing the return removes the implicit any escape hatch and ensures the compiler catches any shape mismatch between the adapter and the SDK call site.

Copy link
Copy Markdown

@rohit-sourcefuse rohit-sourcefuse left a comment

Choose a reason for hiding this comment

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

Good progress on this round — most of the original feedback has been addressed cleanly. Closing those threads now.

A few issues remain that need attention before this is ready to merge:

Must fix before merge:

  • cancelSubscription (stripe.service.ts) now throws after a successful cancel. The provider call succeeds but the error path is hit unconditionally, so callers never receive the cancellation result and get a confusing error instead. The throw should only fire when the API call itself fails.
  • items.data[0] in updateSubscription (stripe.service.ts) is accessed without checking that the array is non-empty. A subscription with no items will throw TypeError: Cannot read properties of undefined at runtime.
  • ChargeBeeBindings.config is injected with {optional: true} but properties on chargeBeeConfig are accessed unconditionally throughout the class. Add a constructor guard that throws a clear startup-time error if the binding is absent.
  • updateSubscription (charge-bee.service.ts) passes subscription_items: [] to Chargebee when priceRefId is absent. Chargebee v2 Items API interprets an empty list as "remove all items", which silently corrupts the subscription.

Nice to have (can go to follow-up):

  • defaultPaymentBehavior in StripeConfig should be typed as Stripe.SubscriptionCreateParams.PaymentBehavior rather than plain string.
  • adaptFromModel on both the Chargebee and Stripe adapters still returns any. The SDK param types are available — typing the return eliminates an implicit escape hatch.

@sourcefuse sourcefuse deleted a comment from rohit-wadhwa Mar 30, 2026
@sourcefuse sourcefuse deleted a comment from rohit-wadhwa Mar 30, 2026
@sourcefuse sourcefuse deleted a comment from rohit-wadhwa Mar 30, 2026
@sourcefuse sourcefuse deleted a comment from rohit-wadhwa Mar 30, 2026
@sourcefuse sourcefuse deleted a comment from rohit-wadhwa Mar 30, 2026
@sourcefuse sourcefuse deleted a comment from rohit-wadhwa Mar 30, 2026
@sourcefuse sourcefuse deleted a comment from rohit-wadhwa Mar 30, 2026
@sourcefuse sourcefuse deleted a comment from rohit-wadhwa Mar 30, 2026
@sourcefuse sourcefuse deleted a comment from rohit-wadhwa Mar 30, 2026
@sourcefuse sourcefuse deleted a comment from rohit-wadhwa Mar 30, 2026
@sourcefuse sourcefuse deleted a comment from rohit-wadhwa Mar 30, 2026
@sourcefuse sourcefuse deleted a comment from rohit-wadhwa Mar 30, 2026
@rohit-sourcefuse rohit-sourcefuse requested review from Copilot and removed request for rohit-wadhwa March 30, 2026 11:11
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 16 out of 18 changed files in this pull request and generated 9 comments.

Comments suppressed due to low confidence (1)

src/providers/sdk/stripe/stripe.service.ts:47

  • stripeConfig is injected with {optional: true}, but the constructor immediately reads stripeConfig.secretKey. If the config binding is missing this will throw at runtime. Either make the injection non-optional, accept StripeConfig | undefined and fail fast with a clear error, or provide a safe default config.
  constructor(
    @inject(StripeBindings.config, {optional: true})
    private readonly stripeConfig: StripeConfig,
  ) {
    this.stripe = new Stripe(stripeConfig.secretKey ?? '', {
      apiVersion: '2024-09-30.acacia', // Update to latest API version as needed
    });

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

const itemId = product.name
.toLowerCase()
.replaceAll(/[^a-z0-9]+/g, '-')
.replaceAll(/^-|-$/g, '');
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

itemId is derived from product.name and can become an empty string (e.g. name contains no alphanumerics) or collide across similarly-named products, which will cause Chargebee item creation to fail. Add validation/fallback (e.g., throw a clear error when the slug is empty, and/or suffix with a short random string when needed).

Suggested change
.replaceAll(/^-|-$/g, '');
.replaceAll(/^-|-$/g, '');
if (!itemId) {
throw new Error(
'Failed to generate Chargebee item ID from product.name. ' +
'The name must contain at least one alphanumeric character.',
);
}

Copilot uses AI. Check for mistakes.
Comment on lines +60 to +62
...(data.daysUntilDue !== undefined && {
days_until_due: data.daysUntilDue,
}),
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

days_until_due is conditionally included whenever daysUntilDue is defined, regardless of collection_method. Stripe only accepts days_until_due for collection_method: 'send_invoice'; passing it for charge_automatically can cause a 400 from Stripe. Gate days_until_due on collectionMethod === SEND_INVOICE (or validate at service layer).

Suggested change
...(data.daysUntilDue !== undefined && {
days_until_due: data.daysUntilDue,
}),
...(data.daysUntilDue !== undefined &&
data.collectionMethod === 'send_invoice' && {
days_until_due: data.daysUntilDue,
}),

Copilot uses AI. Check for mistakes.
Comment on lines +66 to +82
/**
* Extracts customer ID from Stripe customer field.
* Handles string ID, expanded Customer object, and DeletedCustomer edge case.
*
* @param customer - Stripe customer field (string | Customer | DeletedCustomer)
* @returns Customer ID or undefined if customer was deleted
*/
private extractCustomerId(
customer: string | Stripe.Customer | Stripe.DeletedCustomer,
): string | undefined {
if (typeof customer === 'string') {
return customer;
}
if (customer && 'id' in customer) {
return customer.id;
}
return undefined;
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

The JSDoc for extractCustomerId says it returns undefined for a DeletedCustomer, but the implementation returns customer.id for any object with an id (including Stripe.DeletedCustomer). Either update the logic to check deleted === true and return undefined, or adjust the docstring to match the actual behavior.

Copilot uses AI. Check for mistakes.
Comment on lines +418 to +422
throw Object.assign(
new Error(
`[StripeService] cancelSubscription: invoice cleanup failed for ${subscriptionId}`,
),
{cause: err},
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

The comment says invoice cleanup errors “should not fail the cancel response”, but the catch block throws, causing cancelSubscription to reject even though the subscription was already cancelled. If cleanup is truly best-effort, swallow/log the error (or emit it via a hook/telemetry) instead of throwing; otherwise update the comment/contract to reflect that cancellation can fail after the cancel call succeeds.

Copilot uses AI. Check for mistakes.
Comment on lines 37 to +49
constructor(
@inject(ChargeBeeBindings.config, {optional: true})
private readonly chargeBeeConfig: ChargeBeeConfig,
) {
// config initialise
chargebee.configure({
site: chargeBeeConfig.site,
api_key: chargeBeeConfig.apiKey,
});
// Only configure the global chargebee singleton when a valid site is
// provided. This prevents a second instantiation with empty config
// (e.g. SDKProvider vs SubscriptionProvider) from resetting the site.
if (chargeBeeConfig?.site) {
chargebee.configure({
site: chargeBeeConfig.site,
api_key: chargeBeeConfig.apiKey,
});
}
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

@inject(..., {optional: true}) allows chargeBeeConfig to be undefined, but later subscription methods unconditionally read this.chargeBeeConfig.defaultItemFamilyId/defaultPricingModel/..., which will throw at runtime if the binding is missing. Either make the injection non-optional, provide defaults in the constructor, or throw a clear error early when config is absent.

Copilot uses AI. Check for mistakes.
* Defaults to `'flat_fee'` (single fixed recurring charge).
* Other Chargebee values: `'per_unit'`, `'tiered'`, `'volume'`, `'stairstep'`.
*/
defaultPricingModel?: string;
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

defaultPricingModel is typed as string even though this module defines a ChargebeePricingModel string-literal union. Using the union type here would prevent invalid pricing model values from being accepted and only failing at runtime when calling Chargebee.

Suggested change
defaultPricingModel?: string;
defaultPricingModel?: ChargebeePricingModel;

Copilot uses AI. Check for mistakes.
Comment on lines +194 to +201
export interface TSubscriptionUpdate {
/** New price / plan reference ID. */
priceRefId?: string;
prorationBehavior?: ProrationBehavior;
/** Billing collection method to use when re-creating an incomplete subscription. */
collectionMethod?: CollectionMethod;
/** Number of days until the invoice is due (applicable for send_invoice). */
daysUntilDue?: number;
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

TSubscriptionUpdate.priceRefId is optional, but both StripeService and ChargeBeeService implementations require a new price to perform an upgrade/downgrade (they error or send empty items otherwise). Consider making priceRefId required here, or clearly separating “plan change” vs “other subscription updates” in the types (and validating accordingly).

Copilot uses AI. Check for mistakes.
Comment on lines +358 to +368
if (existing.status === 'incomplete') {
// Cancel the incomplete subscription and create a fresh one so the
// customer gets a new payment confirmation link.
await this.stripe.subscriptions.cancel(subscriptionId);
const newId = await this.createSubscription({
customerId: existing.customer as string,
priceRefId: updates.priceRefId ?? '',
collectionMethod:
updates.collectionMethod ?? CollectionMethod.CHARGE_AUTOMATICALLY,
daysUntilDue: updates.daysUntilDue,
});
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

In the incomplete path, existing.customer is cast to string, but Stripe may return an expanded Customer/DeletedCustomer object. This can lead to passing [object Object] as the customer ID and creating a broken subscription. Extract the ID safely (similar to StripeSubscriptionAdapter.extractCustomerId) and avoid falling back to an empty priceRefId (fail fast if no new price is provided).

Copilot uses AI. Check for mistakes.
Comment on lines +454 to +459
try {
const result = await chargebee.subscription
.update_for_items(subscriptionId, {
subscription_items: updates.priceRefId
? [{item_price_id: updates.priceRefId}]
: [],
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

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

update_for_items is called with subscription_items: [] when updates.priceRefId is missing. That can lead to an invalid request or unintended removal of subscription items. Validate that priceRefId is provided for plan changes (and throw a clear error), or explicitly document/support “no item change” updates and omit the field instead of sending an empty array.

Suggested change
try {
const result = await chargebee.subscription
.update_for_items(subscriptionId, {
subscription_items: updates.priceRefId
? [{item_price_id: updates.priceRefId}]
: [],
if (!updates.priceRefId) {
throw new Error(
'ChargeBeeService.updateSubscription: "priceRefId" is required to update subscription items.',
);
}
try {
const result = await chargebee.subscription
.update_for_items(subscriptionId, {
subscription_items: [{item_price_id: updates.priceRefId}],

Copilot uses AI. Check for mistakes.
- chargebee: guard invoice.options?.autoCollection against undefined
- stripe: fix createInvoice to skip shipping_details when not provided
- stripe: fix line2 concatenation (undefined undefined) bug
- stripe: buildShippingDetails returns undefined for empty name
- stripe: fix autoAdvnace typo to autoAdvance across service/adapter/type

GH-1
@sonarqubecloud
Copy link
Copy Markdown

SonarQube reviewer guide

Summary: Add comprehensive subscription lifecycle management to the billing library by implementing recurring billing operations (create, update, cancel, pause, resume) for both Stripe and Chargebee providers, with provider-agnostic types and full unit test coverage.

Review Focus:

  • New ISubscriptionService interface in types.ts and its implementation across both Stripe and Chargebee services — ensure the abstraction properly normalizes provider differences (e.g., Items/ItemPrices in Chargebee vs Products/Prices in Stripe)
  • Subscription adapters for each provider and their test coverage — verify mapping correctness and edge case handling (especially incomplete subscriptions in Stripe)
  • Configuration changes: optional site validation in ChargeBeeService constructor and new config types for each provider to prevent initialization issues
  • Billing provider refactoring that removes delegation methods in favor of returning the provider directly — ensure this change is backward compatible

Start review at: src/types.ts. This file defines the new ISubscriptionService interface and all supporting types (TSubscriptionCreate, TSubscriptionResult, TPrice, etc.) that anchor the entire feature. Understanding the contract here is essential before reviewing provider-specific implementations, as all adapters and service methods must satisfy these types.

💬 Please send your feedback

Quality Gate Passed Quality Gate passed

Issues
0 New issues
0 Accepted issues

Measures
0 Security Hotspots
0.0% Coverage on New Code
0.0% Duplication on New Code

See analysis details on SonarQube Cloud

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Enhance Subscription Lifecycle Management in loopback4-billing

5 participants