Skip to content

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

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

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

Conversation

@piyushsinghgaur1
Copy link

@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

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
@sonarqubecloud
Copy link

SonarQube reviewer guide

Summary: Adds comprehensive recurring-subscription management for both Stripe and Chargebee providers, including product/price creation, subscription lifecycle operations (create/update/cancel/pause/resume), and invoice management, with extensive unit tests.

Review Focus:

  • New ISubscriptionService interface and its implementations in both providers—verify the adapter patterns correctly map provider-specific responses to normalized types
  • Unit test coverage for edge cases (incomplete subscriptions in Stripe, proration behavior, invoice voiding)
  • Configuration changes to prevent chargebee reconfiguration on multiple instantiations
  • Ensure the new SubscriptionProvider binding key integrates properly with existing component architecture

Start review at: src/types.ts. This file defines the new subscription types (TProduct, TPrice, TSubscriptionCreate/Update/Result, ISubscriptionService) that form the contract all other changes depend on—reviewing it first establishes the API surface and makes the downstream provider implementations easier to validate.

💬 Please send your feedback

Quality Gate Passed Quality Gate passed

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

@piyushsinghgaur1 piyushsinghgaur1 self-assigned this Mar 25, 2026
@piyushsinghgaur1 piyushsinghgaur1 added the enhancement New feature or request label Mar 25, 2026
try {
// Chargebee requires an explicit ItemPrice ID or auto-generates one.
const priceId =
price.id ?? `${price.product}-${price.currency}-${Date.now()}`;

Choose a reason for hiding this comment

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

Date.now() has millisecond granularity — two concurrent calls for the same product/currency combination within the same millisecond produce an identical ID, causing a Chargebee conflict error.

Math.random() is not a safe alternative — SonarQube flags it under S2245 (insecure PRNG). Use crypto.randomUUID() instead, which is a Node.js built-in (v14.17+), cryptographically secure, and passes SonarQube checks.

import {randomUUID} from crypto;

// inside createPrice:
const priceId =
  price.id ??
  `${price.product}-${price.currency}-${randomUUID().split(-)[0]}`;

Taking only the first UUID segment (8 hex chars) keeps the ID short and Chargebee-friendly while guaranteeing uniqueness.

// (based on site notification settings).
await chargebee.invoice
.collect_payment(invoiceId, {
payment_source_id: undefined,

Choose a reason for hiding this comment

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

Passing payment_source_id: undefined explicitly may serialize as "payment_source_id": null in the request body depending on the SDK version, which differs from omitting the field entirely and can cause a 400 from Chargebee.

Omit the field instead:

async sendPaymentLink(invoiceId: string): Promise<void> {
  try {
    await chargebee.invoice.collect_payment(invoiceId, {}).request();
  } catch (error) {
    throw new Error(JSON.stringify(error));
  }
}

If a specific payment source needs to be targeted in the future, it can be added as an optional parameter then.

// Chargebee throws a JSON error string with api_error_code for not-found
const message = JSON.stringify(error);
if (
message.includes('resource_not_found') ||

Choose a reason for hiding this comment

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

String-matching on JSON.stringify(error) to detect resource_not_found is fragile. If the Chargebee SDK changes its error serialization, or if an unrelated error message coincidentally contains those strings, this will silently return false when it should throw.

The Chargebee SDK throws structured objects with api_error_code — check it directly:

async checkProductExists(productId: string): Promise<boolean> {
  try {
    const result = await chargebee.item.retrieve(productId).request();
    return result.item.status === 'active';
  } catch (error) {
    const cbError = error as {api_error_code?: string; http_status?: number};
    if (
      cbError.api_error_code === 'resource_not_found' ||
      cbError.http_status === 404
    ) {
      return false;
    }
    throw new Error(JSON.stringify(error));
  }
}

const newId = await this.createSubscription({
customerId: existing.customer as string,
priceRefId: updates.priceRefId ?? '',
collectionMethod: CollectionMethod.CHARGE_AUTOMATICALLY,

Choose a reason for hiding this comment

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

When the existing subscription is incomplete, the replacement is always created with CollectionMethod.CHARGE_AUTOMATICALLY, ignoring the caller's original intent. A customer on a send_invoice plan would silently have their billing workflow switched to automatic charge on every plan change — this has direct revenue and compliance implications.

Pass collectionMethod from updates and also add collectionMethod and daysUntilDue to TSubscriptionUpdate:

// In TSubscriptionUpdate (types.ts):
export interface TSubscriptionUpdate {
  priceRefId?: string;
  prorationBehavior?: ProrationBehavior;
  collectionMethod?: CollectionMethod;
  daysUntilDue?: number;
}

// In updateSubscription (stripe.service.ts):
if (existing.status === 'incomplete') {
  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,
  });
  return {
    id: newId,
    status: 'incomplete',
    customerId: existing.customer as string,
  };
}

Copy link
Collaborator

Choose a reason for hiding this comment

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

valid point

);
} catch (err) {
// Non-fatal — subscription is already cancelled in Stripe; log for observability
console.info(

Choose a reason for hiding this comment

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

console.info in production library code is flagged by SonarQube (S2228 / S106). More critically, for a billing library, silently swallowing invoice cleanup failures via console can mask money-related inconsistencies in production with no structured observability.

Use Error.cause (Node 16.9+) to surface the failure as a structured error that APM tools and callers can observe:

} 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(
    new Error(
      `[StripeService] cancelSubscription: invoice cleanup failed for ${subscriptionId}`,
    ),
    {cause: err},
  );
}

If swallowing the error is intentional design, replace with an injected ILogger — never console directly in a library.

async resumeSubscription(subscriptionId: string): Promise<void> {
await this.stripe.subscriptions.update(subscriptionId, {
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
pause_collection: '' as any, // NOSONAR – Stripe uses empty string to clear pause_collection

Choose a reason for hiding this comment

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

as any with // NOSONAR is a double suppression — it removes all TypeScript type checking for this expression and silences SonarQube. If Stripe ever stops accepting an empty string to clear pause_collection, subscriptions will remain paused indefinitely with no compile-time or runtime signal.

Cast through unknown instead. This is the TypeScript-idiomatic approach for intentional type overrides — it does not trigger SonarQube S3799 and makes the intent explicit:

async resumeSubscription(subscriptionId: string): Promise<void> {
  await this.stripe.subscriptions.update(subscriptionId, {
    // Stripe clears pause_collection by passing an empty string.
    // The SDK types do not model this; cast through unknown to preserve
    // intent without using any.
    // Ref: https://stripe.com/docs/billing/subscriptions/pause-payment
    pause_collection:
      '' as unknown as Stripe.SubscriptionUpdateParams.PauseCollection,
  });
}

export interface TSubscriptionResult {
id: string;
status: string;
customerId: string;

Choose a reason for hiding this comment

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

customerId is typed as string (non-optional), but StripeSubscriptionAdapter.adaptToModel can return undefined for it — specifically when sub.customer is a Stripe.DeletedCustomer object that has no id property. Any downstream code that assumes customerId is always present will throw at runtime.

Widen the type and guard in the adapter:

// types.ts
export interface TSubscriptionResult {
  id: string;
  status: string;
  customerId?: string;  // optional — customer may be deleted or unexpanded
  currentPeriodStart?: number;
  currentPeriodEnd?: number;
  cancelAtPeriodEnd?: boolean;
}

// stripe/adapter/subscription.adapter.ts
adaptToModel(resp: Stripe.Subscription): TSubscriptionResult {
  return {
    id: resp.id,
    status: resp.status,
    customerId:
      typeof resp.customer === 'string'
        ? resp.customer
        : resp.customer && 'id' in resp.customer
          ? resp.customer.id
          : undefined,
    currentPeriodStart: resp.current_period_start,
    currentPeriodEnd: resp.current_period_end,
    cancelAtPeriodEnd: resp.cancel_at_period_end,
  };
}

): Promise<TSubscriptionResult>;

/**
* Cancels a subscription immediately with proration and voids open invoices.

Choose a reason for hiding this comment

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

The docstring says "Cancels a subscription immediately with proration and voids open invoices." This describes Stripe-specific behavior. The Chargebee implementation applies a pro-rated credit note and does not void invoices. Interface-level documentation defines the contract for all implementors — a provider-specific description breaks that abstraction.

/**
 * Cancels a subscription. Providers may apply proration, credit notes, or
 * invoice voiding automatically based on their own billing rules.
 * After this call the subscription will no longer renew.
 */
cancelSubscription(subscriptionId: string): Promise<void>;

* @param resp - Raw Chargebee Subscription returned by the SDK.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
adaptToModel(resp: any): TSubscriptionResult {

Choose a reason for hiding this comment

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

adaptToModel accepts any with // NOSONAR. This removes all type safety for the most-used method in the adapter and will trigger SonarQube. The Chargebee SDK does not export a typed subscription object, but a local interface covers the fields we map — no any needed:

interface RawChargebeeSubscription {
  id: string;
  status: string;
  customer_id: string;
  current_term_start?: number;
  current_term_end?: number;
  cancel_at_period_end?: boolean;
}

adaptToModel(resp: RawChargebeeSubscription): TSubscriptionResult {
  return {
    id: resp.id,
    status: resp.status,
    customerId: resp.customer_id,
    currentPeriodStart: resp.current_term_start,
    currentPeriodEnd: resp.current_term_end,
    cancelAtPeriodEnd: resp.cancel_at_period_end ?? false,
  };
}

This also makes the @example in the JSDoc above type-safe for consumers who subclass the adapter.

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

Choose a reason for hiding this comment

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

adaptFromModel is implemented here but never called. ChargeBeeService.createSubscription builds its Chargebee params inline instead of delegating to the adapter — meaning adaptToModel is used for responses but adaptFromModel is bypassed for requests. This is dead code that will mislead the next contributor.

Either wire it up consistently:

// charge-bee.service.ts — createSubscription
async createSubscription(subscription: TSubscriptionCreate): Promise<string> {
  try {
    const params = this.chargebeeSubscriptionAdapter.adaptFromModel(subscription);
    const result = await chargebee.subscription
      .create_with_items(subscription.customerId, params)
      .request();
    return result.subscription.id;
  } catch (error) {
    throw new Error(JSON.stringify(error));
  }
}

Or remove adaptFromModel entirely if the inline approach is intentional.

* @param resp - Raw Stripe Subscription returned by the SDK.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
adaptToModel(resp: any): TSubscriptionResult {

Choose a reason for hiding this comment

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

Same issue as in the Chargebee adapter — any here is unnecessary since the Stripe SDK already exports Stripe.Subscription. Use the typed parameter directly, which also removes the eslint-disable comment and eliminates the SonarQube flag:

adaptToModel(resp: Stripe.Subscription): TSubscriptionResult {
  return {
    id: resp.id,
    status: resp.status,
    customerId:
      typeof resp.customer === 'string'
        ? resp.customer
        : resp.customer && 'id' in resp.customer
          ? resp.customer.id
          : undefined,
    currentPeriodStart: resp.current_period_start,
    currentPeriodEnd: resp.current_period_end,
    cancelAtPeriodEnd: resp.cancel_at_period_end,
  };
}

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

Choose a reason for hiding this comment

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

adaptFromModel is implemented but never called — StripeService.createSubscription builds its params inline rather than using this adapter. This is dead code and breaks the symmetry with adaptToModel.

Either invoke it in createSubscription:

// stripe.service.ts — createSubscription
async createSubscription(subscription: TSubscriptionCreate): Promise<string> {
  const params = this.stripeSubscriptionAdapter.adaptFromModel(subscription);
  const created = await this.stripe.subscriptions.create({
    ...params,
    payment_behavior: (this.stripeConfig.defaultPaymentBehavior ??
      'default_incomplete') as Stripe.SubscriptionCreateParams.PaymentBehavior,
  });
  return created.id;
}

Or remove adaptFromModel entirely. Leaving it unconnected will confuse the next contributor who assumes it is part of the data flow.

Copy link

@rohit-wadhwa rohit-wadhwa left a comment

Choose a reason for hiding this comment

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

Good foundation for the subscription lifecycle feature — the ISubscriptionService interface is well-designed, the adapter pattern is consistent with the rest of the library, and the test coverage is solid for a first pass. A few things need to be addressed before this is ready to merge.

Must fix:

  • Trivy scan is failing. The package-lock.json changes introduce dependency tree adjustments that have triggered CVEs. Run trivy fs --scanners vuln --format table . locally, triage the findings, and either patch or document them in a tracking issue before merge. Merging a billing library with an open Trivy failure is not acceptable.
  • customerId type unsafetyTSubscriptionResult.customerId is typed as non-optional string but StripeSubscriptionAdapter can produce undefined for it (deleted customer edge case). Inline comment on src/types.ts line 206.
  • Hard-coded CollectionMethod.CHARGE_AUTOMATICALLY in updateSubscription's incomplete path — silently overrides a send_invoice customer's billing workflow. TSubscriptionUpdate also needs collectionMethod and daysUntilDue fields. Inline comment on stripe.service.ts line 367.
  • checkProductExists string-matching on serialized error in Chargebee — fragile against SDK changes. Check api_error_code directly on the structured error object. Inline comment on charge-bee.service.ts line 606.
  • adaptFromModel dead code on both subscription adapters — implemented but never called, creating a false contract for subclassers. Inline comments on both adapter files.
  • console.info in cancelSubscription — SonarQube S2228/S106 flag, and unsafe for a billing library. Use Error.cause or an injected logger. Inline comment on stripe.service.ts line 417.

Nice to have / backlog:

  • Replace Date.now() with crypto.randomUUID() for collision-safe priceId fallback in Chargebee createPrice (inline comment line 357).
  • Replace '' as any with '' as unknown as Stripe.SubscriptionUpdateParams.PauseCollection in resumeSubscription to eliminate the double suppression (inline comment line 444).
  • Replace any in both adapter adaptToModel signatures — Stripe exports Stripe.Subscription directly; Chargebee needs a small local interface. Removes the eslint-disable and NOSONAR comments.
  • Fix sendPaymentLink in Chargebee to pass {} instead of {payment_source_id: undefined} (inline comment line 584).
  • Update cancelSubscription JSDoc in ISubscriptionService to be provider-agnostic — current wording is Stripe-specific.
  • Remove DRAFT status and update PR description once the above are addressed.

Copy link

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

items: [{price: subscription.priceRefId}],
collection_method: subscription.collectionMethod,
days_until_due: subscription.daysUntilDue,
payment_behavior: (this.stripeConfig.defaultPaymentBehavior ??
Copy link
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

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

4 participants