diff --git a/src/modules/billing/billing.controller.ts b/src/modules/billing/billing.controller.ts index df36d2d..e6579d2 100644 --- a/src/modules/billing/billing.controller.ts +++ b/src/modules/billing/billing.controller.ts @@ -18,7 +18,7 @@ export class BillingController { async create( @Body({ validate: true }) payload: GeneratePaymentLinkDto, ): Promise> { - const url = await this.billingService.upgradePlan( + const id = await this.billingService.upgradePlan( payload.projectId!, payload.planId, payload.countryCode, @@ -28,7 +28,7 @@ export class BillingController { status: 'successful', message: 'Subscription plan upgraded successfully', data: { - url, + id, }, }; } diff --git a/src/modules/billing/billing.interface.ts b/src/modules/billing/billing.interface.ts index b18837e..9dc2bcb 100644 --- a/src/modules/billing/billing.interface.ts +++ b/src/modules/billing/billing.interface.ts @@ -7,5 +7,5 @@ export interface BillingSubscriptionRequest { } export interface BillingSubscriptionResponse { - url: string; + id: string; } diff --git a/src/modules/billing/billing.service.ts b/src/modules/billing/billing.service.ts index 9b5c773..4ea9781 100644 --- a/src/modules/billing/billing.service.ts +++ b/src/modules/billing/billing.service.ts @@ -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 { @@ -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', @@ -133,7 +148,7 @@ export class BillingService { }, ); - return response.checkout.url; + return response.id; } async getPlans(): Promise { @@ -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' @@ -331,7 +358,7 @@ export class BillingService { paymentChannel: 'paddle', paymentReference: invoiceRef, currency: 'USD', - status: InvoiceStatus.PENDING, + status: InvoicePaymentStatus.PENDING, dateCreated: new Date(), }); if (!invoicePaymentId) { @@ -521,4 +548,36 @@ export class BillingService { const jsonString = decoder.decode(new Uint8Array(buffer)); return JSON.parse(jsonString) as Record; } + + private async handleWhenSubscriptionExistWithFailedPaymentOrUnpaidInvoice( + existingSubscription: Subscription, + ): Promise { + 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; + } } diff --git a/src/modules/cron/billing.cron.ts b/src/modules/cron/billing.cron.ts index 2c4038c..5084c23 100644 --- a/src/modules/cron/billing.cron.ts +++ b/src/modules/cron/billing.cron.ts @@ -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 { 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); @@ -20,8 +21,9 @@ export function MonitorPaymentStatus(): Promise { 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...'); @@ -119,6 +121,7 @@ export function SubscriptionRenewalSetup(): Promise { 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); diff --git a/src/modules/repositories/billing/billing.repository.ts b/src/modules/repositories/billing/billing.repository.ts index fcf2323..d876328 100644 --- a/src/modules/repositories/billing/billing.repository.ts +++ b/src/modules/repositories/billing/billing.repository.ts @@ -13,6 +13,7 @@ import { Invoice, InvoiceItem, InvoicePayment, + InvoiceWithId, ListInvoicePayment, SetUpSubscription, Subscription, @@ -152,6 +153,25 @@ export class BillingRepository { } } + @withDbContext + async getInvoiceBySubscriptionId( + subscriptionId: number, + context?: DbContext, + ): Promise { + 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, @@ -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, @@ -553,7 +572,7 @@ export class BillingRepository { paymentReference, amount: plan.price, currency: plan.currency, - status: InvoiceStatus.PENDING, + status: InvoicePaymentStatus.PENDING, dateCreated: new Date(), }; @@ -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 { + const getProjectActiveSubscriptionResult = await context?.query({ + text: queries.getProjectSubscriptionByProjectIdAndPlanId, + values: [projectId, planId], }); if (!getProjectActiveSubscriptionResult?.rowCount) { return null; @@ -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; @@ -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; @@ -711,14 +740,12 @@ export class BillingRepository { @withDbContext async getInvoicePaymentsByStatus( - status: string, + status: InvoicePaymentStatus, context?: DbContext, ): Promise { const result = await context?.query({ text: queries.getInvoicePaymentByStatus, values: [status], - validateResult: (r) => !!r.rowCount, - validationErrorMessage: 'Failed to get invoice payments', }); return ( result?.rows.map( @@ -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( @@ -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( diff --git a/src/modules/repositories/billing/queries.ts b/src/modules/repositories/billing/queries.ts index 1582280..e421fd1 100644 --- a/src/modules/repositories/billing/queries.ts +++ b/src/modules/repositories/billing/queries.ts @@ -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', @@ -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, diff --git a/src/modules/repositories/billing/types.ts b/src/modules/repositories/billing/types.ts index 017df77..b692303 100644 --- a/src/modules/repositories/billing/types.ts +++ b/src/modules/repositories/billing/types.ts @@ -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 { @@ -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; }