Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/modules/billing/billing.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export class BillingController {
async create(
@Body({ validate: true }) payload: GeneratePaymentLinkDto,
): Promise<JsonResponse<BillingSubscriptionResponse>> {
const url = await this.billingService.upgradePlan(
const id = await this.billingService.upgradePlan(
payload.projectId!,
payload.planId,
payload.countryCode,
Expand All @@ -28,7 +28,7 @@ export class BillingController {
status: 'successful',
message: 'Subscription plan upgraded successfully',
data: {
url,
id,
},
};
}
Expand Down
2 changes: 1 addition & 1 deletion src/modules/billing/billing.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@ export interface BillingSubscriptionRequest {
}

export interface BillingSubscriptionResponse {
url: string;
id: string;
}
69 changes: 64 additions & 5 deletions src/modules/billing/billing.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { MailgunSender, MailTemplate } from '../mailing';
import { BillingRepository, Subscription } from '../repositories/billing';
import { PlanSummaryDto, ProjectRepository } from '../repositories/projects';
import { InvoicePaymentStatus, InvoiceStatus } from '../shared/types/invoice';
import { SubscriptionStatus } from '../shared/types/subscriptions';

@Service()
export class BillingService {
Expand Down Expand Up @@ -69,9 +70,23 @@ export class BillingService {
errorCode: applicationErrorCodes.notFound,
});
}
// check if project already has an unpaid invoice for this plan
// or has an active subscription on this plan
const existingSubscription =
await this.billingRepository.getProjectSubscriptionByProjectIdAndPlanId(projectId, planId);
if (existingSubscription !== null) {
const paddleTransactionId =
await this.handleWhenSubscriptionExistWithFailedPaymentOrUnpaidInvoice(
existingSubscription,
);
if (paddleTransactionId) {
return paddleTransactionId;
}
}

// get project previous subscription
const previousSubscription =
await this.billingRepository.getProjectActiveSubscription(projectId);
const previousSubscription = await this.billingRepository.getProjectLastSubscription(projectId);
await this.billingRepository.getProjectActiveSubscription(projectId);
if (!previousSubscription) {
throw new ApplicationError({
message: 'Project does not have an active subscription',
Expand Down Expand Up @@ -133,7 +148,7 @@ export class BillingService {
},
);

return response.checkout.url;
return response.id;
}

async getPlans(): Promise<PlanSummaryDto[]> {
Expand Down Expand Up @@ -162,8 +177,20 @@ export class BillingService {
const bonadocsType = data.custom_data.bonadocs_type;
const invoiceId = Number(transaction!.custom_data.invoice_id);
const invoicePaymentId = Number(transaction!.custom_data.invoice_payment_id);
// check if invoicePayment is valid and has been handled
const invoicePayment = await this.billingRepository.getInvoicePaymentById(invoicePaymentId);
if (invoicePayment === null) {
throw new ApplicationError({
message: 'Invoice payment not found',
logger: this.logger,
errorCode: applicationErrorCodes.notFound,
});
}
if (invoicePayment!.status === InvoicePaymentStatus.SUCCESSFUL) {
this.logger.info(`Invoice payment with id ${invoicePaymentId} has already been handled`);
return;
}
const subscriptionId = Number(transaction!.custom_data.subscription_id);

if (
(origin === 'api' && bonadocsType === 'new_subscription') ||
bonadocsType === 'new_subscription'
Expand Down Expand Up @@ -331,7 +358,7 @@ export class BillingService {
paymentChannel: 'paddle',
paymentReference: invoiceRef,
currency: 'USD',
status: InvoiceStatus.PENDING,
status: InvoicePaymentStatus.PENDING,
dateCreated: new Date(),
});
if (!invoicePaymentId) {
Expand Down Expand Up @@ -521,4 +548,36 @@ export class BillingService {
const jsonString = decoder.decode(new Uint8Array(buffer));
return JSON.parse(jsonString) as Record<string, any>;
}

private async handleWhenSubscriptionExistWithFailedPaymentOrUnpaidInvoice(
existingSubscription: Subscription,
): Promise<string | null> {
if (existingSubscription?.status === SubscriptionStatus.Active) {
throw new ApplicationError({
message: 'Project already has an active subscription on this plan',
logger: this.logger,
errorCode: applicationErrorCodes.invalidRequest,
});
}
// check if invoice is paid but not yet set up
const existingSubscriptionInvoice = await this.billingRepository.getInvoiceBySubscriptionId(
existingSubscription!.id,
);
if (existingSubscriptionInvoice && existingSubscriptionInvoice.status === InvoiceStatus.PAID) {
throw new ApplicationError({
message: 'Project already has an active subscription on this plan',
logger: this.logger,
errorCode: applicationErrorCodes.invalidRequest,
});
}
if (existingSubscriptionInvoice?.status === InvoiceStatus.UNPAID) {
const invoicePayment = await this.billingRepository.getInvoicePaymentById(
existingSubscriptionInvoice.id,
);
if (invoicePayment && invoicePayment.status === InvoicePaymentStatus.PENDING) {
return existingSubscription!.metadata.paddleTransactionId;
}
}
return null;
}
}
9 changes: 6 additions & 3 deletions src/modules/cron/billing.cron.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,12 @@ import { BonadocsLogger } from '@bonadocs/logger';
import { BillingService } from '../billing/billing.service';
import { PaddleHttpClient } from '../http/paddle-http-client';
import { BillingRepository } from '../repositories/billing';
import { InvoicePaymentStatus } from '../shared/types/invoice';

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

try {
const pendingInvoicePayments =
await billingRepository.getInvoicePaymentsByStatus('pending');
const pendingInvoicePayments = await billingRepository.getInvoicePaymentsByStatus(
InvoicePaymentStatus.PENDING,
);

if (!pendingInvoicePayments.length) {
logger.info('No pending invoice payments found. Skipping...');
Expand Down Expand Up @@ -119,6 +121,7 @@ export function SubscriptionRenewalSetup(): Promise<void> {
logger.info(`Subscription renewal setup executed for ${x.id}`);
});
await Promise.all(promises);
resolve();
logger.info(`Subscription renewal setup executed for ${subscriptionsToProcess.length}`);
} catch (error) {
logger.error('Error occurred while running subscription renewal setup', error);
Expand Down
53 changes: 38 additions & 15 deletions src/modules/repositories/billing/billing.repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
Invoice,
InvoiceItem,
InvoicePayment,
InvoiceWithId,
ListInvoicePayment,
SetUpSubscription,
Subscription,
Expand Down Expand Up @@ -152,6 +153,25 @@ export class BillingRepository {
}
}

@withDbContext
async getInvoiceBySubscriptionId(
subscriptionId: number,
context?: DbContext,
): Promise<InvoiceWithId | undefined> {
try {
const getInvoiceResult = await context?.query({
text: queries.getInvoiceBySubscriptionId,
values: [subscriptionId],
validateResult: (result) => !!result.rowCount,
validationErrorMessage: 'Failed to get invoice',
});
return getInvoiceResult?.rows[0];
} catch (error) {
this.logger.error('Failed to get invoice', error);
return undefined;
}
}

@withDbContext
async updateInvoicePaymentStatus(
id: number,
Expand Down Expand Up @@ -530,7 +550,6 @@ export class BillingRepository {
status: InvoiceStatus.PENDING,
dateCreated: new Date(),
dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
// todo how do we determine the due date?
};
const createInvoiceResult = await context?.query({
text: queries.createInvoice,
Expand All @@ -553,7 +572,7 @@ export class BillingRepository {
paymentReference,
amount: plan.price,
currency: plan.currency,
status: InvoiceStatus.PENDING,
status: InvoicePaymentStatus.PENDING,
dateCreated: new Date(),
};

Expand Down Expand Up @@ -666,8 +685,22 @@ export class BillingRepository {
const getProjectActiveSubscriptionResult = await context?.query({
text: queries.getProjectActiveSubscription,
values: [projectId, SubscriptionStatus.Active],
validateResult: (result) => !!result.rowCount,
validationErrorMessage: 'Failed to get project active subscription',
});
if (!getProjectActiveSubscriptionResult?.rowCount) {
return null;
}
return getProjectActiveSubscriptionResult.rows[0];
}

@withDbContext
async getProjectSubscriptionByProjectIdAndPlanId(
projectId: number,
planId: number,
context?: DbContext,
): Promise<Subscription | null> {
const getProjectActiveSubscriptionResult = await context?.query({
text: queries.getProjectSubscriptionByProjectIdAndPlanId,
values: [projectId, planId],
});
if (!getProjectActiveSubscriptionResult?.rowCount) {
return null;
Expand All @@ -683,8 +716,6 @@ export class BillingRepository {
const getProjectActiveSubscriptionResult = await context?.query({
text: queries.getProjectLastSubscription,
values: [projectId],
validateResult: (result) => !!result.rowCount,
validationErrorMessage: 'Failed to get project last subscription',
});
if (!getProjectActiveSubscriptionResult?.rowCount) {
return null;
Expand All @@ -700,8 +731,6 @@ export class BillingRepository {
const getSubscriptionResult = await context?.query({
text: queries.getSubscriptionById,
values: [subscriptionId],
validateResult: (result) => !!result.rowCount,
validationErrorMessage: 'Failed to get subscription',
});
if (!getSubscriptionResult?.rowCount) {
return null;
Expand All @@ -711,14 +740,12 @@ export class BillingRepository {

@withDbContext
async getInvoicePaymentsByStatus(
status: string,
status: InvoicePaymentStatus,
context?: DbContext,
): Promise<ListInvoicePayment[]> {
const result = await context?.query({
text: queries.getInvoicePaymentByStatus,
values: [status],
validateResult: (r) => !!r.rowCount,
validationErrorMessage: 'Failed to get invoice payments',
});
return (
result?.rows.map(
Expand All @@ -743,8 +770,6 @@ export class BillingRepository {
const result = await context?.query({
text: queries.getAlmostDueSubscriptions,
values: [now, in7Days],
validateResult: (r) => !!r.rowCount,
validationErrorMessage: 'Failed to get almost due subscriptions',
});
return (
result?.rows.map(
Expand All @@ -765,8 +790,6 @@ export class BillingRepository {
const result = await context?.query({
text: queries.getDueSubscriptions,
values: [now, SubscriptionStatus.Active],
validateResult: (r) => !!r.rowCount,
validationErrorMessage: 'Failed to get almost due subscriptions',
});
return (
result?.rows.map(
Expand Down
6 changes: 5 additions & 1 deletion src/modules/repositories/billing/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export const queries = {
updateInvoiceStatus: 'UPDATE "bonadocs"."invoices" SET status = $2 WHERE id = $1',
getInvoiceById:
'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',
getInvoiceBySubscriptionId:
'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',
updateInvoicePaymentStatus: 'UPDATE "bonadocs"."invoice_payments" SET status = $2 WHERE id = $1',
getInvoicePaymentById:
'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',
Expand Down Expand Up @@ -35,12 +37,14 @@ export const queries = {
'SELECT id FROM "bonadocs"."subscriptions" WHERE project_id = $1 AND status = $2 LIMIT 1',
getProjectActiveSubscription:
'SELECT id, project_id, plan_id, metadata, status, date_expires FROM "bonadocs"."subscriptions" WHERE project_id = $1 AND status = $2 LIMIT 1',
getProjectSubscriptionByProjectIdAndPlanId:
'SELECT id, project_id, plan_id, metadata, status, date_expires FROM "bonadocs"."subscriptions" WHERE project_id = $1 AND plan_id = $2 LIMIT 1',
getProjectLastSubscription:
'SELECT id, project_id, plan_id, metadata, status, date_expires FROM "bonadocs"."subscriptions" WHERE project_id = $1 ORDER BY date_created DESC LIMIT 1',
getSubscriptionById:
'SELECT id, project_id, plan_id, metadata, status, date_expires FROM "bonadocs"."subscriptions" WHERE id = $1 LIMIT 1',
getInvoicePaymentByStatus:
'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',
'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',
getAlmostDueSubscriptions: `
SELECT s.id,
s.project_id,
Expand Down
8 changes: 6 additions & 2 deletions src/modules/repositories/billing/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { InvoiceStatus } from '../../shared/types/invoice';
import { InvoicePaymentStatus, InvoiceStatus } from '../../shared/types/invoice';
import { SubscriptionStatus } from '../../shared/types/subscriptions';

export interface Invoice {
Expand All @@ -11,13 +11,17 @@ export interface Invoice {
dueDate: Date;
}

export interface InvoiceWithId extends Invoice {
id: number;
}

export interface InvoicePayment {
invoiceId: number;
paymentChannel: string;
paymentReference: string;
amount: number;
currency: string;
status: InvoiceStatus;
status: InvoicePaymentStatus;
dateCreated: Date;
}

Expand Down
Loading