Skip to content

Commit d19443a

Browse files
committed
feat(billing): refactor billing process to return subscription ID instead of URL; add invoice retrieval by subscription ID
1 parent 02a03af commit d19443a

7 files changed

Lines changed: 122 additions & 29 deletions

File tree

src/modules/billing/billing.controller.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ export class BillingController {
1818
async create(
1919
@Body({ validate: true }) payload: GeneratePaymentLinkDto,
2020
): Promise<JsonResponse<BillingSubscriptionResponse>> {
21-
const url = await this.billingService.upgradePlan(
21+
const id = await this.billingService.upgradePlan(
2222
payload.projectId!,
2323
payload.planId,
2424
payload.countryCode,
@@ -28,7 +28,7 @@ export class BillingController {
2828
status: 'successful',
2929
message: 'Subscription plan upgraded successfully',
3030
data: {
31-
url,
31+
id,
3232
},
3333
};
3434
}

src/modules/billing/billing.interface.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,5 @@ export interface BillingSubscriptionRequest {
77
}
88

99
export interface BillingSubscriptionResponse {
10-
url: string;
10+
id: string;
1111
}

src/modules/billing/billing.service.ts

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { MailgunSender, MailTemplate } from '../mailing';
1414
import { BillingRepository, Subscription } from '../repositories/billing';
1515
import { PlanSummaryDto, ProjectRepository } from '../repositories/projects';
1616
import { InvoicePaymentStatus, InvoiceStatus } from '../shared/types/invoice';
17+
import { SubscriptionStatus } from '../shared/types/subscriptions';
1718

1819
@Service()
1920
export class BillingService {
@@ -69,9 +70,23 @@ export class BillingService {
6970
errorCode: applicationErrorCodes.notFound,
7071
});
7172
}
73+
// check if project already has an unpaid invoice for this plan
74+
// or has an active subscription on this plan
75+
const existingSubscription =
76+
await this.billingRepository.getProjectSubscriptionByProjectIdAndPlanId(projectId, planId);
77+
if (existingSubscription !== null) {
78+
const paddleTransactionId =
79+
await this.handleWhenSubscriptionExistWithFailedPaymentOrUnpaidInvoice(
80+
existingSubscription,
81+
);
82+
if (paddleTransactionId) {
83+
return paddleTransactionId;
84+
}
85+
}
86+
7287
// get project previous subscription
73-
const previousSubscription =
74-
await this.billingRepository.getProjectActiveSubscription(projectId);
88+
const previousSubscription = await this.billingRepository.getProjectLastSubscription(projectId);
89+
await this.billingRepository.getProjectActiveSubscription(projectId);
7590
if (!previousSubscription) {
7691
throw new ApplicationError({
7792
message: 'Project does not have an active subscription',
@@ -133,7 +148,7 @@ export class BillingService {
133148
},
134149
);
135150

136-
return response.checkout.url;
151+
return response.id;
137152
}
138153

139154
async getPlans(): Promise<PlanSummaryDto[]> {
@@ -162,8 +177,20 @@ export class BillingService {
162177
const bonadocsType = data.custom_data.bonadocs_type;
163178
const invoiceId = Number(transaction!.custom_data.invoice_id);
164179
const invoicePaymentId = Number(transaction!.custom_data.invoice_payment_id);
180+
// check if invoicePayment is valid and has been handled
181+
const invoicePayment = await this.billingRepository.getInvoicePaymentById(invoicePaymentId);
182+
if (invoicePayment === null) {
183+
throw new ApplicationError({
184+
message: 'Invoice payment not found',
185+
logger: this.logger,
186+
errorCode: applicationErrorCodes.notFound,
187+
});
188+
}
189+
if (invoicePayment!.status === InvoicePaymentStatus.SUCCESSFUL) {
190+
this.logger.info(`Invoice payment with id ${invoicePaymentId} has already been handled`);
191+
return;
192+
}
165193
const subscriptionId = Number(transaction!.custom_data.subscription_id);
166-
167194
if (
168195
(origin === 'api' && bonadocsType === 'new_subscription') ||
169196
bonadocsType === 'new_subscription'
@@ -331,7 +358,7 @@ export class BillingService {
331358
paymentChannel: 'paddle',
332359
paymentReference: invoiceRef,
333360
currency: 'USD',
334-
status: InvoiceStatus.PENDING,
361+
status: InvoicePaymentStatus.PENDING,
335362
dateCreated: new Date(),
336363
});
337364
if (!invoicePaymentId) {
@@ -521,4 +548,36 @@ export class BillingService {
521548
const jsonString = decoder.decode(new Uint8Array(buffer));
522549
return JSON.parse(jsonString) as Record<string, any>;
523550
}
551+
552+
private async handleWhenSubscriptionExistWithFailedPaymentOrUnpaidInvoice(
553+
existingSubscription: Subscription,
554+
): Promise<string | null> {
555+
if (existingSubscription?.status === SubscriptionStatus.Active) {
556+
throw new ApplicationError({
557+
message: 'Project already has an active subscription on this plan',
558+
logger: this.logger,
559+
errorCode: applicationErrorCodes.invalidRequest,
560+
});
561+
}
562+
// check if invoice is paid but not yet set up
563+
const existingSubscriptionInvoice = await this.billingRepository.getInvoiceBySubscriptionId(
564+
existingSubscription!.id,
565+
);
566+
if (existingSubscriptionInvoice && existingSubscriptionInvoice.status === InvoiceStatus.PAID) {
567+
throw new ApplicationError({
568+
message: 'Project already has an active subscription on this plan',
569+
logger: this.logger,
570+
errorCode: applicationErrorCodes.invalidRequest,
571+
});
572+
}
573+
if (existingSubscriptionInvoice?.status === InvoiceStatus.UNPAID) {
574+
const invoicePayment = await this.billingRepository.getInvoicePaymentById(
575+
existingSubscriptionInvoice.id,
576+
);
577+
if (invoicePayment && invoicePayment.status === InvoicePaymentStatus.PENDING) {
578+
return existingSubscription!.metadata.paddleTransactionId;
579+
}
580+
}
581+
return null;
582+
}
524583
}

src/modules/cron/billing.cron.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,12 @@ import { BonadocsLogger } from '@bonadocs/logger';
77
import { BillingService } from '../billing/billing.service';
88
import { PaddleHttpClient } from '../http/paddle-http-client';
99
import { BillingRepository } from '../repositories/billing';
10+
import { InvoicePaymentStatus } from '../shared/types/invoice';
1011

1112
// cron to check due payments and send reminders
1213
export function MonitorPaymentStatus(): Promise<void> {
1314
return new Promise((resolve, reject) => {
14-
cron.schedule('*/15 * * * *', async () => {
15+
cron.schedule('*/1 * * * *', async () => {
1516
const billingRepository = Container.get(BillingRepository);
1617
const paddleHttpClient = Container.get(PaddleHttpClient);
1718
const billingService = Container.get(BillingService);
@@ -20,8 +21,9 @@ export function MonitorPaymentStatus(): Promise<void> {
2021
logger.info('Started Monitor Payment Cron Job');
2122

2223
try {
23-
const pendingInvoicePayments =
24-
await billingRepository.getInvoicePaymentsByStatus('pending');
24+
const pendingInvoicePayments = await billingRepository.getInvoicePaymentsByStatus(
25+
InvoicePaymentStatus.PENDING,
26+
);
2527

2628
if (!pendingInvoicePayments.length) {
2729
logger.info('No pending invoice payments found. Skipping...');
@@ -119,6 +121,7 @@ export function SubscriptionRenewalSetup(): Promise<void> {
119121
logger.info(`Subscription renewal setup executed for ${x.id}`);
120122
});
121123
await Promise.all(promises);
124+
resolve();
122125
logger.info(`Subscription renewal setup executed for ${subscriptionsToProcess.length}`);
123126
} catch (error) {
124127
logger.error('Error occurred while running subscription renewal setup', error);

src/modules/repositories/billing/billing.repository.ts

Lines changed: 38 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import {
1313
Invoice,
1414
InvoiceItem,
1515
InvoicePayment,
16+
InvoiceWithId,
1617
ListInvoicePayment,
1718
SetUpSubscription,
1819
Subscription,
@@ -152,6 +153,25 @@ export class BillingRepository {
152153
}
153154
}
154155

156+
@withDbContext
157+
async getInvoiceBySubscriptionId(
158+
subscriptionId: number,
159+
context?: DbContext,
160+
): Promise<InvoiceWithId | undefined> {
161+
try {
162+
const getInvoiceResult = await context?.query({
163+
text: queries.getInvoiceBySubscriptionId,
164+
values: [subscriptionId],
165+
validateResult: (result) => !!result.rowCount,
166+
validationErrorMessage: 'Failed to get invoice',
167+
});
168+
return getInvoiceResult?.rows[0];
169+
} catch (error) {
170+
this.logger.error('Failed to get invoice', error);
171+
return undefined;
172+
}
173+
}
174+
155175
@withDbContext
156176
async updateInvoicePaymentStatus(
157177
id: number,
@@ -530,7 +550,6 @@ export class BillingRepository {
530550
status: InvoiceStatus.PENDING,
531551
dateCreated: new Date(),
532552
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
533-
// todo how do we determine the due date?
534553
};
535554
const createInvoiceResult = await context?.query({
536555
text: queries.createInvoice,
@@ -553,7 +572,7 @@ export class BillingRepository {
553572
paymentReference,
554573
amount: plan.price,
555574
currency: plan.currency,
556-
status: InvoiceStatus.PENDING,
575+
status: InvoicePaymentStatus.PENDING,
557576
dateCreated: new Date(),
558577
};
559578

@@ -666,8 +685,22 @@ export class BillingRepository {
666685
const getProjectActiveSubscriptionResult = await context?.query({
667686
text: queries.getProjectActiveSubscription,
668687
values: [projectId, SubscriptionStatus.Active],
669-
validateResult: (result) => !!result.rowCount,
670-
validationErrorMessage: 'Failed to get project active subscription',
688+
});
689+
if (!getProjectActiveSubscriptionResult?.rowCount) {
690+
return null;
691+
}
692+
return getProjectActiveSubscriptionResult.rows[0];
693+
}
694+
695+
@withDbContext
696+
async getProjectSubscriptionByProjectIdAndPlanId(
697+
projectId: number,
698+
planId: number,
699+
context?: DbContext,
700+
): Promise<Subscription | null> {
701+
const getProjectActiveSubscriptionResult = await context?.query({
702+
text: queries.getProjectSubscriptionByProjectIdAndPlanId,
703+
values: [projectId, planId],
671704
});
672705
if (!getProjectActiveSubscriptionResult?.rowCount) {
673706
return null;
@@ -683,8 +716,6 @@ export class BillingRepository {
683716
const getProjectActiveSubscriptionResult = await context?.query({
684717
text: queries.getProjectLastSubscription,
685718
values: [projectId],
686-
validateResult: (result) => !!result.rowCount,
687-
validationErrorMessage: 'Failed to get project last subscription',
688719
});
689720
if (!getProjectActiveSubscriptionResult?.rowCount) {
690721
return null;
@@ -700,8 +731,6 @@ export class BillingRepository {
700731
const getSubscriptionResult = await context?.query({
701732
text: queries.getSubscriptionById,
702733
values: [subscriptionId],
703-
validateResult: (result) => !!result.rowCount,
704-
validationErrorMessage: 'Failed to get subscription',
705734
});
706735
if (!getSubscriptionResult?.rowCount) {
707736
return null;
@@ -711,14 +740,12 @@ export class BillingRepository {
711740

712741
@withDbContext
713742
async getInvoicePaymentsByStatus(
714-
status: string,
743+
status: InvoicePaymentStatus,
715744
context?: DbContext,
716745
): Promise<ListInvoicePayment[]> {
717746
const result = await context?.query({
718747
text: queries.getInvoicePaymentByStatus,
719748
values: [status],
720-
validateResult: (r) => !!r.rowCount,
721-
validationErrorMessage: 'Failed to get invoice payments',
722749
});
723750
return (
724751
result?.rows.map(
@@ -743,8 +770,6 @@ export class BillingRepository {
743770
const result = await context?.query({
744771
text: queries.getAlmostDueSubscriptions,
745772
values: [now, in7Days],
746-
validateResult: (r) => !!r.rowCount,
747-
validationErrorMessage: 'Failed to get almost due subscriptions',
748773
});
749774
return (
750775
result?.rows.map(
@@ -765,8 +790,6 @@ export class BillingRepository {
765790
const result = await context?.query({
766791
text: queries.getDueSubscriptions,
767792
values: [now, SubscriptionStatus.Active],
768-
validateResult: (r) => !!r.rowCount,
769-
validationErrorMessage: 'Failed to get almost due subscriptions',
770793
});
771794
return (
772795
result?.rows.map(

src/modules/repositories/billing/queries.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ export const queries = {
88
updateInvoiceStatus: 'UPDATE "bonadocs"."invoices" SET status = $2 WHERE id = $1',
99
getInvoiceById:
1010
'SELECT i.id, i.subscription_id, i.reference, i.amount, i.currency, i.status, i.date_created, i.due_date FROM "bonadocs"."invoices" i WHERE id = $1 LIMIT 1',
11+
getInvoiceBySubscriptionId:
12+
'SELECT i.id, i.subscription_id, i.reference, i.amount, i.currency, i.status, i.date_created, i.due_date FROM "bonadocs"."invoices" i WHERE subscription_id = $1 LIMIT 1',
1113
updateInvoicePaymentStatus: 'UPDATE "bonadocs"."invoice_payments" SET status = $2 WHERE id = $1',
1214
getInvoicePaymentById:
1315
'SELECT ip.id, ip.invoice_id, ip.payment_channel, ip.payment_reference, ip.amount, ip.currency, ip.date_created, ip.status FROM "bonadocs"."invoice_payments" ip WHERE id = $1 LIMIT 1',
@@ -35,12 +37,14 @@ export const queries = {
3537
'SELECT id FROM "bonadocs"."subscriptions" WHERE project_id = $1 AND status = $2 LIMIT 1',
3638
getProjectActiveSubscription:
3739
'SELECT id, project_id, plan_id, metadata, status, date_expires FROM "bonadocs"."subscriptions" WHERE project_id = $1 AND status = $2 LIMIT 1',
40+
getProjectSubscriptionByProjectIdAndPlanId:
41+
'SELECT id, project_id, plan_id, metadata, status, date_expires FROM "bonadocs"."subscriptions" WHERE project_id = $1 AND plan_id = $2 LIMIT 1',
3842
getProjectLastSubscription:
3943
'SELECT id, project_id, plan_id, metadata, status, date_expires FROM "bonadocs"."subscriptions" WHERE project_id = $1 ORDER BY date_created DESC LIMIT 1',
4044
getSubscriptionById:
4145
'SELECT id, project_id, plan_id, metadata, status, date_expires FROM "bonadocs"."subscriptions" WHERE id = $1 LIMIT 1',
4246
getInvoicePaymentByStatus:
43-
'SELECT ip.id, ip.invoice_id, ip.payment_channel, ip.payment_reference, ip.amount, ip.currency, ip.date_created, ip.status, ip.metadata FROM "bonadocs"."invoice_payment" ip where status = $1',
47+
'SELECT ip.id, ip.invoice_id, ip.payment_channel, ip.payment_reference, ip.amount, ip.currency, ip.date_created, ip.status, ip.metadata FROM "bonadocs"."invoice_payments" ip where status = $1',
4448
getAlmostDueSubscriptions: `
4549
SELECT s.id,
4650
s.project_id,

src/modules/repositories/billing/types.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { InvoiceStatus } from '../../shared/types/invoice';
1+
import { InvoicePaymentStatus, InvoiceStatus } from '../../shared/types/invoice';
22
import { SubscriptionStatus } from '../../shared/types/subscriptions';
33

44
export interface Invoice {
@@ -11,13 +11,17 @@ export interface Invoice {
1111
dueDate: Date;
1212
}
1313

14+
export interface InvoiceWithId extends Invoice {
15+
id: number;
16+
}
17+
1418
export interface InvoicePayment {
1519
invoiceId: number;
1620
paymentChannel: string;
1721
paymentReference: string;
1822
amount: number;
1923
currency: string;
20-
status: InvoiceStatus;
24+
status: InvoicePaymentStatus;
2125
dateCreated: Date;
2226
}
2327

0 commit comments

Comments
 (0)