Skip to content

Commit 0a1ea48

Browse files
Piyush Singh GaurPiyush Singh Gaur
authored andcommitted
refactor(chore): address PR review comments for type safety and error handling
address PR review comments for type safety and error handling GH-21
1 parent 2d8499c commit 0a1ea48

6 files changed

Lines changed: 71 additions & 135 deletions

File tree

src/providers/billing.provider.ts

Lines changed: 3 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,7 @@
11
import {inject, Provider} from '@loopback/core';
2-
32
import {HttpErrors} from '@loopback/rest';
43
import {BillingComponentBindings} from '../keys';
5-
import {
6-
IService,
7-
TCustomer,
8-
TInvoice,
9-
TPaymentSource,
10-
Transaction,
11-
} from '../types';
4+
import {IService} from '../types';
125

136
export class BillingProvider implements Provider<IService> {
147
constructor(
@@ -18,7 +11,7 @@ export class BillingProvider implements Provider<IService> {
1811
private readonly sdkProvider?: IService,
1912
) {}
2013

21-
getProvider() {
14+
getProvider(): IService {
2215
if (this.sdkProvider && this.restProvider) {
2316
throw new HttpErrors.NotAcceptable();
2417
} else if (this.sdkProvider) {
@@ -30,75 +23,7 @@ export class BillingProvider implements Provider<IService> {
3023
}
3124
}
3225

33-
async createCustomer(customerDto: TCustomer): Promise<TCustomer> {
34-
return this.getProvider().createCustomer(customerDto);
35-
}
36-
37-
async getCustomers(customerId: string): Promise<TCustomer> {
38-
return this.getProvider().getCustomers(customerId);
39-
}
40-
41-
async updateCustomerById(
42-
tenantId: string,
43-
customerDto: Partial<TCustomer>,
44-
): Promise<void> {
45-
return this.getProvider().updateCustomerById(tenantId, customerDto);
46-
}
47-
48-
async deleteCustomer(customerId: string): Promise<void> {
49-
return this.getProvider().deleteCustomer(customerId);
50-
}
51-
52-
async createPaymentSource(
53-
paymentDto: TPaymentSource,
54-
): Promise<TPaymentSource> {
55-
return this.getProvider().createPaymentSource(paymentDto);
56-
}
57-
58-
async applyPaymentSourceForInvoice(
59-
invoiceId: string,
60-
transaction: Transaction,
61-
): Promise<TInvoice> {
62-
return this.getProvider().applyPaymentSourceForInvoice(
63-
invoiceId,
64-
transaction,
65-
);
66-
}
67-
68-
async retrievePaymentSource(
69-
paymentSourceId: string,
70-
): Promise<TPaymentSource> {
71-
return this.getProvider().retrievePaymentSource(paymentSourceId);
72-
}
73-
74-
async deletePaymentSource(paymentSourceId: string): Promise<void> {
75-
return this.getProvider().deletePaymentSource(paymentSourceId);
76-
}
77-
78-
async createInvoice(invoice: TInvoice): Promise<TInvoice> {
79-
return this.getProvider().createInvoice(invoice);
80-
}
81-
82-
async retrieveInvoice(invoiceId: string): Promise<TInvoice> {
83-
return this.getProvider().retrieveInvoice(invoiceId);
84-
}
85-
86-
async updateInvoice(
87-
invoiceId: string,
88-
invoice: Partial<TInvoice>,
89-
): Promise<TInvoice> {
90-
return this.getProvider().updateInvoice(invoiceId, invoice);
91-
}
92-
93-
async deleteInvoice(invoiceId: string): Promise<void> {
94-
return this.getProvider().deleteInvoice(invoiceId);
95-
}
96-
97-
async getPaymentStatus(invoiceId: string): Promise<boolean> {
98-
return this.getProvider().getPaymentStatus(invoiceId);
99-
}
100-
101-
value() {
26+
value(): IService {
10227
return this.getProvider();
10328
}
10429
}

src/providers/sdk/chargebee/adapter/subscription.adapter.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,20 @@ import {
55
TSubscriptionResult,
66
} from '../../../../types';
77

8+
/**
9+
* Local interface covering the Chargebee Subscription fields we map.
10+
* Chargebee does not export a typed subscription object from its SDK,
11+
* so we define the shape ourselves — no `any` needed.
12+
*/
13+
export interface RawChargebeeSubscription {
14+
id: string;
15+
status: string;
16+
customer_id: string;
17+
current_term_start?: number;
18+
current_term_end?: number;
19+
cancel_at_period_end?: boolean;
20+
}
21+
822
/**
923
* Adapter that converts between the raw Chargebee Subscription object and the
1024
* provider-agnostic {@link TSubscriptionResult} shape used throughout the
@@ -17,10 +31,9 @@ import {
1731
* @example
1832
* ```ts
1933
* class MyAdapter extends ChargebeeSubscriptionAdapter {
20-
* adaptToModel(resp: unknown): TSubscriptionResult {
34+
* adaptToModel(resp: RawChargebeeSubscription & {trial_end?: number}): TSubscriptionResult {
2135
* const base = super.adaptToModel(resp);
22-
* const raw = resp as {trial_end?: number};
23-
* return {...base, trialEnd: raw.trial_end};
36+
* return {...base, trialEnd: resp.trial_end};
2437
* }
2538
* }
2639
* // then:
@@ -36,9 +49,7 @@ export class ChargebeeSubscriptionAdapter
3649
*
3750
* @param resp - Raw Chargebee Subscription returned by the SDK.
3851
*/
39-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
40-
adaptToModel(resp: any): TSubscriptionResult {
41-
// NOSONAR
52+
adaptToModel(resp: RawChargebeeSubscription): TSubscriptionResult {
4253
return {
4354
id: resp.id,
4455
status: resp.status,

src/providers/sdk/chargebee/charge-bee.service.ts

Lines changed: 13 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
/* eslint-disable @typescript-eslint/naming-convention */
2+
import {randomUUID} from 'crypto';
23
import {inject} from '@loopback/core';
34
import chargebee from 'chargebee';
45
import {
5-
CollectionMethod,
66
RecurringInterval,
77
TInvoicePrice,
88
TPrice,
@@ -353,8 +353,11 @@ export class ChargeBeeService implements IChargeBeeService {
353353
async createPrice(price: TPrice): Promise<TPrice> {
354354
try {
355355
// Chargebee requires an explicit ItemPrice ID or auto-generates one.
356+
// randomUUID() is cryptographically secure (Node.js built-in v14.17+);
357+
// the first 8-hex segment keeps the ID short and Chargebee-friendly.
356358
const priceId =
357-
price.id ?? `${price.product}-${price.currency}-${Date.now()}`;
359+
price.id ??
360+
`${price.product}-${price.currency}-${randomUUID().split('-')[0]}`;
358361

359362
const result = await chargebee.item_price
360363
.create({
@@ -410,24 +413,10 @@ export class ChargeBeeService implements IChargeBeeService {
410413
*/
411414
async createSubscription(subscription: TSubscriptionCreate): Promise<string> {
412415
try {
416+
const params =
417+
this.chargebeeSubscriptionAdapter.adaptFromModel(subscription);
413418
const result = await chargebee.subscription
414-
.create_with_items(subscription.customerId, {
415-
subscription_items: [
416-
{
417-
item_price_id: subscription.priceRefId,
418-
},
419-
],
420-
discounts: [], // Required by Chargebee SDK type
421-
...(subscription.collectionMethod === CollectionMethod.SEND_INVOICE
422-
? {
423-
auto_collection: 'off' as const,
424-
// Only include net_term_days if explicitly provided and site has payment terms configured
425-
...(subscription.daysUntilDue !== undefined
426-
? {net_term_days: subscription.daysUntilDue}
427-
: {}),
428-
}
429-
: {auto_collection: 'on' as const}),
430-
})
419+
.create_with_items(subscription.customerId, params)
431420
.request();
432421
return result.subscription.id;
433422
} catch (error) {
@@ -579,11 +568,7 @@ export class ChargeBeeService implements IChargeBeeService {
579568
// Using collect_payment without a payment_source triggers Chargebee to
580569
// send the hosted payment page link to the customer by email
581570
// (based on site notification settings).
582-
await chargebee.invoice
583-
.collect_payment(invoiceId, {
584-
payment_source_id: undefined,
585-
})
586-
.request();
571+
await chargebee.invoice.collect_payment(invoiceId, {}).request();
587572
} catch (error) {
588573
throw new Error(JSON.stringify(error));
589574
}
@@ -600,15 +585,14 @@ export class ChargeBeeService implements IChargeBeeService {
600585
const result = await chargebee.item.retrieve(productId).request();
601586
return result.item.status === 'active';
602587
} catch (error) {
603-
// Chargebee throws a JSON error string with api_error_code for not-found
604-
const message = JSON.stringify(error);
588+
const cbError = error as {api_error_code?: string; http_status?: number};
605589
if (
606-
message.includes('resource_not_found') ||
607-
message.includes('not_found')
590+
cbError.api_error_code === 'resource_not_found' ||
591+
cbError.http_status === 404
608592
) {
609593
return false;
610594
}
611-
throw new Error(message);
595+
throw new Error(JSON.stringify(error));
612596
}
613597
}
614598
}

src/providers/sdk/stripe/adapter/subscription.adapter.ts

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,19 @@ export class StripeSubscriptionAdapter
3333
*
3434
* @param resp - Raw Stripe Subscription returned by the SDK.
3535
*/
36-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
37-
adaptToModel(resp: any): TSubscriptionResult {
38-
const sub = resp as Stripe.Subscription;
36+
adaptToModel(resp: Stripe.Subscription): TSubscriptionResult {
3937
return {
40-
id: sub.id,
41-
status: sub.status,
38+
id: resp.id,
39+
status: resp.status,
4240
customerId:
43-
typeof sub.customer === 'string' ? sub.customer : sub.customer?.id,
44-
currentPeriodStart: sub.current_period_start,
45-
currentPeriodEnd: sub.current_period_end,
46-
cancelAtPeriodEnd: sub.cancel_at_period_end,
41+
typeof resp.customer === 'string'
42+
? resp.customer
43+
: resp.customer && 'id' in resp.customer
44+
? resp.customer.id
45+
: undefined,
46+
currentPeriodStart: resp.current_period_start,
47+
currentPeriodEnd: resp.current_period_end,
48+
cancelAtPeriodEnd: resp.cancel_at_period_end,
4749
};
4850
}
4951

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

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -314,11 +314,9 @@ export class StripeService implements IStripeService {
314314
* @returns The Stripe subscription ID.
315315
*/
316316
async createSubscription(subscription: TSubscriptionCreate): Promise<string> {
317+
const params = this.stripeSubscriptionAdapter.adaptFromModel(subscription);
317318
const created = await this.stripe.subscriptions.create({
318-
customer: subscription.customerId,
319-
items: [{price: subscription.priceRefId}],
320-
collection_method: subscription.collectionMethod,
321-
days_until_due: subscription.daysUntilDue,
319+
...params,
322320
payment_behavior: (this.stripeConfig.defaultPaymentBehavior ??
323321
'default_incomplete') as Stripe.SubscriptionCreateParams.PaymentBehavior,
324322
});
@@ -364,7 +362,9 @@ export class StripeService implements IStripeService {
364362
const newId = await this.createSubscription({
365363
customerId: existing.customer as string,
366364
priceRefId: updates.priceRefId ?? '',
367-
collectionMethod: CollectionMethod.CHARGE_AUTOMATICALLY,
365+
collectionMethod:
366+
updates.collectionMethod ?? CollectionMethod.CHARGE_AUTOMATICALLY,
367+
daysUntilDue: updates.daysUntilDue,
368368
});
369369
return {
370370
id: newId,
@@ -413,10 +413,13 @@ export class StripeService implements IStripeService {
413413
}),
414414
);
415415
} catch (err) {
416-
// Non-fatal — subscription is already cancelled in Stripe; log for observability
417-
console.info(
418-
'[StripeService] cancelSubscription: invoice cleanup failed',
419-
err,
416+
// Invoice cleanup is best-effort after cancellation.
417+
// Surface as a structured error so callers and APM tools can observe it.
418+
throw Object.assign(
419+
new Error(
420+
`[StripeService] cancelSubscription: invoice cleanup failed for ${subscriptionId}`,
421+
),
422+
{cause: err},
420423
);
421424
}
422425
}
@@ -440,8 +443,12 @@ export class StripeService implements IStripeService {
440443
*/
441444
async resumeSubscription(subscriptionId: string): Promise<void> {
442445
await this.stripe.subscriptions.update(subscriptionId, {
443-
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
444-
pause_collection: '' as any, // NOSONAR – Stripe uses empty string to clear pause_collection
446+
// Stripe clears pause_collection by passing an empty string.
447+
// The SDK types do not model this; cast through unknown to preserve
448+
// intent without using any.
449+
// Ref: https://stripe.com/docs/billing/subscriptions/pause-payment
450+
pause_collection:
451+
'' as unknown as Stripe.SubscriptionUpdateParams.PauseCollection,
445452
});
446453
}
447454

src/types.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,10 @@ export interface TSubscriptionUpdate {
195195
/** New price / plan reference ID. */
196196
priceRefId?: string;
197197
prorationBehavior?: ProrationBehavior;
198+
/** Billing collection method to use when re-creating an incomplete subscription. */
199+
collectionMethod?: CollectionMethod;
200+
/** Number of days until the invoice is due (applicable for send_invoice). */
201+
daysUntilDue?: number;
198202
}
199203

200204
/**
@@ -203,7 +207,8 @@ export interface TSubscriptionUpdate {
203207
export interface TSubscriptionResult {
204208
id: string;
205209
status: string;
206-
customerId: string;
210+
/** Optional — customer may be deleted or unexpanded by the provider. */
211+
customerId?: string;
207212
currentPeriodStart?: number;
208213
currentPeriodEnd?: number;
209214
cancelAtPeriodEnd?: boolean;
@@ -257,7 +262,9 @@ export interface ISubscriptionService {
257262
): Promise<TSubscriptionResult>;
258263

259264
/**
260-
* Cancels a subscription immediately with proration and voids open invoices.
265+
* Cancels a subscription. Providers may apply proration, credit notes, or
266+
* invoice voiding automatically based on their own billing rules.
267+
* After this call the subscription will no longer renew.
261268
*/
262269
cancelSubscription(subscriptionId: string): Promise<void>;
263270

0 commit comments

Comments
 (0)