feat(provider): implement a comprehensive subscription lifecycle#20
feat(provider): implement a comprehensive subscription lifecycle#20piyushsinghgaur1 wants to merge 6 commits intomasterfrom
Conversation
…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 Remediation AgentSonarQube found 3 issues in this PR that the agent can fix for you. Est. time saved: ~16 min 3 issues found
|
14307d8 to
ecde827
Compare
…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
There was a problem hiding this comment.
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
ISubscriptionServiceinterface 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
testscript to run compiled tests vialb-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.configis marked{optional: true}, but the constructor dereferencesstripeConfig.secretKeyunconditionally. If the binding isn’t present in a consuming app, this will throw at startup. Either make the injection non-optional, or acceptStripeConfig | undefinedand 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.
| 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, | ||
| }); |
There was a problem hiding this comment.
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).
| 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); |
| 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}], | ||
| }); |
There was a problem hiding this comment.
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.
| 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; |
There was a problem hiding this comment.
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.
| 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(); | ||
| }); | ||
|
|
There was a problem hiding this comment.
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.
| customerId: existing.customer as string, | ||
| }; | ||
| } | ||
|
|
There was a problem hiding this comment.
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.
| 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', | |
| ); | |
| } |
| 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, | ||
| }); | ||
| } |
There was a problem hiding this comment.
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.
| 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', | ||
| }) |
There was a problem hiding this comment.
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.
| 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) |
| .collect_payment(invoiceId, { | ||
| payment_source_id: undefined, | ||
| }) |
There was a problem hiding this comment.
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.
| .collect_payment(invoiceId, { | |
| payment_source_id: undefined, | |
| }) | |
| .collect_payment(invoiceId, {}) |
| 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(); | ||
| }); |
There was a problem hiding this comment.
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.
| * other gateway implementation) here so controllers and services can inject | ||
| * subscription capabilities independently of one-time billing. | ||
| */ | ||
| export const SubscriptionProvider = BindingKey.create<ISubscriptionService>( |
There was a problem hiding this comment.
what is the need of it
| */ | ||
| export interface TProduct { | ||
| name: string; | ||
| description?: string; |
There was a problem hiding this comment.
does product only have these field in it?
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
does subscription update only allow these two fields to update?
There was a problem hiding this comment.
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 ?? |
There was a problem hiding this comment.
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
5170b52 to
799fd29
Compare
| } 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( |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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}) |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
rohit-sourcefuse
left a comment
There was a problem hiding this comment.
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]inupdateSubscription(stripe.service.ts) is accessed without checking that the array is non-empty. A subscription with no items will throwTypeError: Cannot read properties of undefinedat runtime.ChargeBeeBindings.configis injected with{optional: true}but properties onchargeBeeConfigare 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) passessubscription_items: []to Chargebee whenpriceRefIdis 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):
defaultPaymentBehaviorinStripeConfigshould be typed asStripe.SubscriptionCreateParams.PaymentBehaviorrather than plainstring.adaptFromModelon both the Chargebee and Stripe adapters still returnsany. The SDK param types are available — typing the return eliminates an implicit escape hatch.
There was a problem hiding this comment.
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
stripeConfigis injected with{optional: true}, but the constructor immediately readsstripeConfig.secretKey. If the config binding is missing this will throw at runtime. Either make the injection non-optional, acceptStripeConfig | undefinedand 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, ''); |
There was a problem hiding this comment.
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).
| .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.', | |
| ); | |
| } |
| ...(data.daysUntilDue !== undefined && { | ||
| days_until_due: data.daysUntilDue, | ||
| }), |
There was a problem hiding this comment.
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).
| ...(data.daysUntilDue !== undefined && { | |
| days_until_due: data.daysUntilDue, | |
| }), | |
| ...(data.daysUntilDue !== undefined && | |
| data.collectionMethod === 'send_invoice' && { | |
| days_until_due: data.daysUntilDue, | |
| }), |
| /** | ||
| * 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; |
There was a problem hiding this comment.
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.
| throw Object.assign( | ||
| new Error( | ||
| `[StripeService] cancelSubscription: invoice cleanup failed for ${subscriptionId}`, | ||
| ), | ||
| {cause: err}, |
There was a problem hiding this comment.
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.
| 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, | ||
| }); | ||
| } |
There was a problem hiding this comment.
@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.
| * Defaults to `'flat_fee'` (single fixed recurring charge). | ||
| * Other Chargebee values: `'per_unit'`, `'tiered'`, `'volume'`, `'stairstep'`. | ||
| */ | ||
| defaultPricingModel?: string; |
There was a problem hiding this comment.
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.
| defaultPricingModel?: string; | |
| defaultPricingModel?: ChargebeePricingModel; |
| 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; |
There was a problem hiding this comment.
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).
| 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, | ||
| }); |
There was a problem hiding this comment.
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).
| try { | ||
| const result = await chargebee.subscription | ||
| .update_for_items(subscriptionId, { | ||
| subscription_items: updates.priceRefId | ||
| ? [{item_price_id: updates.priceRefId}] | ||
| : [], |
There was a problem hiding this comment.
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.
| 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}], |
- 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
SonarQube reviewer guideSummary: 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:
Start review at:
|



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
ISubscriptionServiceto the binding keys insrc/keys.ts, allowing controllers and services to inject subscription capabilities independently of one-time billing. Introduced aSubscriptionProviderbinding key for this purpose. [1] [2]value()method to return the result ofgetProvider(), streamlining service instantiation.Chargebee Subscription Implementation
ISubscriptionServiceinterface inChargeBeeService, 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]ChargebeeSubscriptionAdapterinsrc/providers/sdk/chargebee/adapter/subscription.adapter.tsfor mapping between Chargebee subscription objects and the library's provider-agnostic types.Type and Interface Enhancements
IChargeBeeServiceinterface to combine both one-time billing and recurring subscription management, with detailed JSDoc mapping library types to Chargebee's API. [1] [2]Configuration Improvements
ChargeBeeConfiginterface 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
testscript inpackage.jsonto run tests usinglb-mochainstead of a placeholder, ensuring automated test execution.