diff --git a/Dockerfile b/Dockerfile index a6682dc..5d09186 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,4 +14,4 @@ WORKDIR /usr/src/app COPY --from=setup /usr/src/app/dist . COPY --from=setup /usr/src/app/node_modules ./node_modules EXPOSE $PORT -ENTRYPOINT [ "node", "server.js" ] +ENTRYPOINT [ "node", "server.js" ] \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index d6d4f6c..44f24ba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6070,7 +6070,6 @@ "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", "dev": true, - "license": "MIT", "bin": { "husky": "bin.js" }, diff --git a/src/modules/billing/billing.controller.ts b/src/modules/billing/billing.controller.ts index 826fb89..ead3d87 100644 --- a/src/modules/billing/billing.controller.ts +++ b/src/modules/billing/billing.controller.ts @@ -1,12 +1,14 @@ import { EventName, Paddle } from '@paddle/paddle-node-sdk'; import { Request } from 'express'; -import { Body, JsonController, Post, Req } from 'routing-controllers'; +import { Body, Get, JsonController, Post, Req } from 'routing-controllers'; import { Inject, Service } from 'typedi'; import { diConstants } from '@bonadocs/di'; import { BonadocsLogger } from '@bonadocs/logger'; import { ConfigService, Paddle as PaddleConfig } from '../configuration'; +import { Subscription } from '../repositories/billing'; +import { PlanSummaryDto } from '../repositories/projects'; import { JsonResponse } from '../shared'; import { BillingSubscriptionResponse } from './billing.interface'; @@ -82,4 +84,25 @@ export class BillingController { message: 'Webhook event could not be handled', }; } + + @Get('/plans') + async getPlans(): Promise> { + const data = await this.billingService.getPlans(); + return { + status: 'successful', + message: 'Plans successfully retrieved', + data, + }; + } + + @Get('/active-subscription') + async getActiveSubscription(@Req() request: Request): Promise> { + const authData = request.auth; + const data = await this.billingService.getActiveSubscriptionPlan(authData.projectId!); + return { + status: 'successful', + message: 'Plans successfully retrieved', + data, + }; + } } diff --git a/src/modules/billing/billing.service.ts b/src/modules/billing/billing.service.ts index 2f0463d..bf03d04 100644 --- a/src/modules/billing/billing.service.ts +++ b/src/modules/billing/billing.service.ts @@ -5,9 +5,9 @@ import { BonadocsLogger } from '@bonadocs/logger'; import { ApplicationError, applicationErrorCodes } from '../errors'; import { PaddleHttpClient } from '../http/paddle-http-client'; -import { MailSender, MailTemplate } from '../mailing'; -import { BillingRepository } from '../repositories/billing'; -import { ProjectRepository } from '../repositories/projects'; +import { MailgunSender, MailTemplate } from '../mailing'; +import { BillingRepository, Subscription } from '../repositories/billing'; +import { PlanSummaryDto, ProjectRepository } from '../repositories/projects'; @Service() export class BillingService { @@ -16,7 +16,7 @@ export class BillingService { @Inject() private readonly billingRepository: BillingRepository, @Inject() private readonly projectRepository: ProjectRepository, @Inject() private readonly paddleHttpClient: PaddleHttpClient, - @Inject() private readonly mailService: MailSender, + @Inject() private readonly mailService: MailgunSender, ) {} async upgradePlan(projectId: number, planId: number): Promise { @@ -113,6 +113,141 @@ export class BillingService { ); } + async getPlans(): Promise { + const plans = this.billingRepository.getPlans(); + return plans; + } + + async getActiveSubscriptionPlan(projectId: number): Promise { + const activeSubscription = await this.billingRepository.getProjectActiveSubscription(projectId); + if (!activeSubscription) { + throw new ApplicationError({ + message: `Project with id ${projectId} has not active subscription`, + logger: this.logger, + errorCode: applicationErrorCodes.notFound, + }); + } + return activeSubscription; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async handleTransactionPaidWebhook(data: Record): Promise { + // handle new subscription charge + const transactionId = data.id; + const transaction = await this.paddleHttpClient.getTransactionById(transactionId); + const origin = data?.origin || null; + const bonadocsType = data.custom_data.bonadocs_type; + const invoiceId = Number(transaction.custom_data.invoice_id); + const invoicePaymentId = Number(transaction.custom_data.invoice_payment_id); + const subscriptionId = Number(transaction.custom_data.subscription_id); + + if ( + (origin === 'api' && bonadocsType === 'new_subscription') || + bonadocsType === 'new_subscription' + ) { + const paddleSubscriptionId = data.subscription_id; + let updatedMetadata: Record | null = null; + + // get new subscription + const newSubscription = await this.billingRepository.getSubscriptionById(subscriptionId); + if (newSubscription === null) { + throw new ApplicationError({ + message: 'Subscription not found', + logger: this.logger, + errorCode: applicationErrorCodes.notFound, + }); + } + const activeSubscriptionId = newSubscription.metadata.previousSubscriptionId; + if (paddleSubscriptionId) { + updatedMetadata = { + ...newSubscription.metadata, + paddleSubscriptionId, + }; + } + + await this.billingRepository.updateOnTransactionPaidForNewAndRecurringSubscription( + newSubscription.id, + activeSubscriptionId, + invoiceId, + 'paid', + invoicePaymentId, + 'successful', + updatedMetadata, + ); + } + + if (origin === 'subscription_recurring') { + const paddleTransactionId = data.id; + let updatedMetadata: Record | null = null; + + // get new subscription + const newSubscription = await this.billingRepository.getSubscriptionById( + Number(subscriptionId), + ); + if (newSubscription === null) { + throw new ApplicationError({ + message: 'Subscription not found', + logger: this.logger, + errorCode: applicationErrorCodes.notFound, + }); + } + const activeSubscriptionId = newSubscription.metadata.previousSubscriptionId; + if (paddleTransactionId) { + updatedMetadata = { + ...newSubscription.metadata, + paddleTransactionId, + }; + } + + await this.billingRepository.updateOnTransactionPaidForNewAndRecurringSubscription( + newSubscription.id, + activeSubscriptionId, + invoiceId, + 'paid', + invoicePaymentId, + 'successful', + updatedMetadata, + ); + } + + if (origin === 'subscription_charge') { + await this.billingRepository.updateTransactionPaidForSubscriptionOverage( + invoiceId, + 'paid', + invoicePaymentId, + 'successful', + ); + } + } + + async handleTransactionPaymentFailedWebhook(): Promise {} + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async handleSubscriptionCreatedWebhook(data: Record): Promise { + const transactionId = data.id; + const transaction = await this.paddleHttpClient.getTransactionById(transactionId); + const subscriptionId = Number(transaction.custom_data.subscription_id); + const paddleSubscriptionId = data.id; + let updatedMetadata: Record = {}; + + // get new subscription + const newSubscription = await this.billingRepository.getSubscriptionById(subscriptionId); + if (newSubscription === null) { + throw new ApplicationError({ + message: 'Subscription not found', + logger: this.logger, + errorCode: applicationErrorCodes.notFound, + }); + } + if (paddleSubscriptionId) { + updatedMetadata = { + ...newSubscription.metadata, + paddleSubscriptionId, + }; + } + await this.billingRepository.updateSubscriptionMetadata(subscriptionId, updatedMetadata); + } + async handlerOverageCharge(projectId: number): Promise { // get project const project = await this.projectRepository.getProjectById(projectId); @@ -246,130 +381,6 @@ export class BillingService { }); } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async handleTransactionPaidWebhook(data: Record): Promise { - // handle new subscription charge - const transactionId = data.id; - const transaction = await this.paddleHttpClient.getTransactionById(transactionId); - const origin = data?.origin || null; - const bonadocsType = data.custom_data.bonadocs_type; - const invoiceId = Number(transaction.custom_data.invoice_id); - const invoicePaymentId = Number(transaction.custom_data.invoice_payment_id); - const subscriptionId = Number(transaction.custom_data.subscription_id); - - if ( - (origin === 'api' && bonadocsType === 'new_subscription') || - bonadocsType === 'new_subscription' - ) { - const paddleSubscriptionId = data.subscription_id; - let updatedMetadata: Record | null = null; - - // get new subscription - const newSubscription = await this.billingRepository.getSubscriptionById(subscriptionId); - if (newSubscription === null) { - throw new ApplicationError({ - message: 'Subscription not found', - logger: this.logger, - errorCode: applicationErrorCodes.notFound, - }); - } - const activeSubscriptionId = newSubscription.metadata.previousSubscriptionId; - if (paddleSubscriptionId) { - updatedMetadata = { - ...newSubscription.metadata, - paddleSubscriptionId, - }; - } - - await this.billingRepository.updateOnTransactionPaidForNewAndRecurringSubscription( - newSubscription.id, - activeSubscriptionId, - invoiceId, - 'paid', - invoicePaymentId, - 'successful', - updatedMetadata, - ); - } - - if (origin === 'subscription_recurring') { - const paddleTransactionId = data.id; - let updatedMetadata: Record | null = null; - - // get new subscription - const newSubscription = await this.billingRepository.getSubscriptionById( - Number(subscriptionId), - ); - if (newSubscription === null) { - throw new ApplicationError({ - message: 'Subscription not found', - logger: this.logger, - errorCode: applicationErrorCodes.notFound, - }); - } - const activeSubscriptionId = newSubscription.metadata.previousSubscriptionId; - if (paddleTransactionId) { - updatedMetadata = { - ...newSubscription.metadata, - paddleTransactionId, - }; - } - - await this.billingRepository.updateOnTransactionPaidForNewAndRecurringSubscription( - newSubscription.id, - activeSubscriptionId, - invoiceId, - 'paid', - invoicePaymentId, - 'successful', - updatedMetadata, - ); - } - - if (origin === 'subscription_charge') { - await this.billingRepository.updateTransactionPaidForSubscriptionOverage( - invoiceId, - 'paid', - invoicePaymentId, - 'successful', - ); - } - } - - async handleTransactionPaymentFailedWebhook(): Promise {} - - async handleTransactionCompletedWebhook(): Promise {} - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - async handleSubscriptionCreatedWebhook(data: Record): Promise { - const transactionId = data.id; - const transaction = await this.paddleHttpClient.getTransactionById(transactionId); - const subscriptionId = Number(transaction.custom_data.subscription_id); - const paddleSubscriptionId = data.id; - let updatedMetadata: Record = {}; - - // get new subscription - const newSubscription = await this.billingRepository.getSubscriptionById(subscriptionId); - if (newSubscription === null) { - throw new ApplicationError({ - message: 'Subscription not found', - logger: this.logger, - errorCode: applicationErrorCodes.notFound, - }); - } - if (paddleSubscriptionId) { - updatedMetadata = { - ...newSubscription.metadata, - paddleSubscriptionId, - }; - } - await this.billingRepository.updateSubscriptionMetadata(subscriptionId, updatedMetadata); - } - - async handleSubscriptionCancelledWebhook(): Promise {} - - async handleSubscriptionUpdatedWebhook(): Promise {} - generateInvoiceRef(): string { const now = new Date(); const pad2 = (n: number) => n.toString().padStart(2, '0'); diff --git a/src/modules/repositories/billing/billing.repository.ts b/src/modules/repositories/billing/billing.repository.ts index 2a50f16..eec6886 100644 --- a/src/modules/repositories/billing/billing.repository.ts +++ b/src/modules/repositories/billing/billing.repository.ts @@ -5,7 +5,7 @@ import { BonadocsLogger } from '@bonadocs/logger'; import { DbContext, withDbContext } from '../../connection/dbcontext'; import { SubscriptionStatus } from '../../shared/types/subscriptions'; -import { PlanResourceDetails, PlanWithMetadataDto } from '../projects'; +import { PlanResourceDetails, PlanSummaryDto, PlanWithMetadataDto } from '../projects'; import { queries } from './queries'; import { @@ -281,7 +281,7 @@ export class BillingRepository { } @withDbContext - async getPlans(context?: DbContext): Promise { + async getPlans(context?: DbContext): Promise { try { const getPlansResult = await context?.query({ text: queries.getAllPlans, @@ -297,7 +297,6 @@ export class BillingRepository { duration: row.duration, active: row.active, dateCreated: row.date_created, - metadata: row.metadata, })) || [] ); } catch (error) { diff --git a/src/modules/repositories/billing/queries.ts b/src/modules/repositories/billing/queries.ts index 768c30c..3c5c026 100644 --- a/src/modules/repositories/billing/queries.ts +++ b/src/modules/repositories/billing/queries.ts @@ -18,7 +18,7 @@ export const queries = { getInvoicePaymentsByInvoiceIds: 'SELECT ip.invoice_id, ip.payment_channel, ip.payment_reference, ip.amount, ip.currency, ip.date_created, ip.status FROM "bonadocs"."invoice_payments" ip WHERE invoice_id = ANY($1)', getAllPlans: - 'SELECT id, name, price, currency, duration, active, date_created, metadata FROM "bonadocs"."plans"', + 'SELECT p.id, p.name, p.price, p.currency, p.duration, p.active, p.date_created, FROM "bonadocs"."plans" AS p', getPlanById: 'SELECT id, name, price, currency, duration, active, date_created, metadata FROM "bonadocs"."plans" WHERE id = $1 LIMIT 1', updateSubscriptionMetadata: 'UPDATE "bonadocs"."subscriptions" SET metadata = $2 WHERE id = $1', diff --git a/src/modules/repositories/projects/types.ts b/src/modules/repositories/projects/types.ts index a21b6bf..25b0118 100644 --- a/src/modules/repositories/projects/types.ts +++ b/src/modules/repositories/projects/types.ts @@ -45,6 +45,13 @@ export interface PlanWithMetadataDto extends PlanSummaryDto { metadata: Record; } +export interface PlanWithResourceDto extends PlanSummaryDto { + quantity: number; + overages_supported: boolean; + overages_price: number; + overages_bundle_count: number; +} + export interface ProjectDTO { id: number; slug: string;