feat(provider): implement a comprehensive subscription lifecycle#20
feat(provider): implement a comprehensive subscription lifecycle#20piyushsinghgaur1 wants to merge 3 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
SonarQube reviewer guideSummary: 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:
Start review at:
|
| try { | ||
| // Chargebee requires an explicit ItemPrice ID or auto-generates one. | ||
| const priceId = | ||
| price.id ?? `${price.product}-${price.currency}-${Date.now()}`; |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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') || |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
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,
};
}| ); | ||
| } catch (err) { | ||
| // Non-fatal — subscription is already cancelled in Stripe; log for observability | ||
| console.info( |
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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 { |
There was a problem hiding this comment.
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.
rohit-wadhwa
left a comment
There was a problem hiding this comment.
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.jsonchanges introduce dependency tree adjustments that have triggered CVEs. Runtrivy 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. customerIdtype unsafety —TSubscriptionResult.customerIdis typed as non-optionalstringbutStripeSubscriptionAdaptercan produceundefinedfor it (deleted customer edge case). Inline comment onsrc/types.tsline 206.- Hard-coded
CollectionMethod.CHARGE_AUTOMATICALLYinupdateSubscription's incomplete path — silently overrides asend_invoicecustomer's billing workflow.TSubscriptionUpdatealso needscollectionMethodanddaysUntilDuefields. Inline comment onstripe.service.tsline 367. checkProductExistsstring-matching on serialized error in Chargebee — fragile against SDK changes. Checkapi_error_codedirectly on the structured error object. Inline comment oncharge-bee.service.tsline 606.adaptFromModeldead code on both subscription adapters — implemented but never called, creating a false contract for subclassers. Inline comments on both adapter files.console.infoincancelSubscription— SonarQube S2228/S106 flag, and unsafe for a billing library. UseError.causeor an injected logger. Inline comment onstripe.service.tsline 417.
Nice to have / backlog:
- Replace
Date.now()withcrypto.randomUUID()for collision-safepriceIdfallback in ChargebeecreatePrice(inline comment line 357). - Replace
'' as anywith'' as unknown as Stripe.SubscriptionUpdateParams.PauseCollectioninresumeSubscriptionto eliminate the double suppression (inline comment line 444). - Replace
anyin both adapteradaptToModelsignatures — Stripe exportsStripe.Subscriptiondirectly; Chargebee needs a small local interface. Removes theeslint-disableand NOSONAR comments. - Fix
sendPaymentLinkin Chargebee to pass{}instead of{payment_source_id: undefined}(inline comment line 584). - Update
cancelSubscriptionJSDoc inISubscriptionServiceto be provider-agnostic — current wording is Stripe-specific. - Remove DRAFT status and update PR description once the above are addressed.
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?
| 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



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.