diff --git a/src/app.ts b/src/app.ts index deccc04..34611cd 100644 --- a/src/app.ts +++ b/src/app.ts @@ -9,7 +9,13 @@ import swaggerUi from 'swagger-ui-express'; import { diMiddleware, useScopedContainer } from '@bonadocs/di'; import { rootLoggerMiddleware } from '@bonadocs/logger'; -import { AppErrorHandler, AuthMiddleware, healthcheckMiddleware, useCors } from './middleware'; +import { + AppErrorHandler, + AuthMiddleware, + healthcheckMiddleware, + useCors, + webhookMiddleware, +} from './middleware'; import { AuthController, BillingController, @@ -26,6 +32,8 @@ const configService = getConfigService(); // add middleware app.use(helmet()); useCors(app); +app.use('/v1/billing/webhook', express.raw({ type: '*/*' })); +app.use(webhookMiddleware); app.use(express.json({ limit: '300kb' })); // add this before the logger to avoid unnecessary healthcheck diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 2cb78df..2a3012f 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -20,7 +20,7 @@ export class AuthMiddleware implements ExpressMiddlewareInterface { async use(request: Request, response: Response, next: (err?: any) => any): Promise { // todo : find a better fix for this - const exemptPaths = ['/auth/login', '/auth/register', '/auth/refresh']; + const exemptPaths = ['/auth/login', '/auth/register', '/auth/refresh', '/billing/webhook']; const isExemptPath = exemptPaths.some((path) => request.path.startsWith(path)); if (isExemptPath) { return next(); diff --git a/src/middleware/index.ts b/src/middleware/index.ts index 4b21971..4f50438 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -2,3 +2,4 @@ export { default as AppErrorHandler } from './errors'; export { default as healthcheckMiddleware } from './healthcheck'; export { useCors } from './cors'; export { AuthMiddleware } from './auth'; +export { default as webhookMiddleware } from './webhook'; diff --git a/src/middleware/webhook.ts b/src/middleware/webhook.ts new file mode 100644 index 0000000..eb1b220 --- /dev/null +++ b/src/middleware/webhook.ts @@ -0,0 +1,53 @@ +import { NextFunction, Request, Response } from 'express'; +import { StatusCodes } from 'http-status-codes'; +import Container from 'typedi'; + +import { getGlobalLogger } from '@bonadocs/di'; + +import { BillingService } from '../modules/billing/billing.service'; + +export default function webhookMiddleware( + request: Request, + response: Response, + next: NextFunction, +): void | Promise { + const billingService = Container.get(BillingService); + const logger = getGlobalLogger(); + if (request.path.startsWith('/v1/billing/webhook')) { + billingService + .verifyWebhookSignature(request) + .then(async (eventData) => { + if (eventData === null) { + return response.status(StatusCodes.BAD_REQUEST).json({ + status: 'failed', + message: 'Unable to verify event signature', + }); + } + + const result = await billingService.handleEvent(eventData); + + if (result) { + return response.status(StatusCodes.OK).json({ + status: 'successful', + message: 'Event handled successfully', + }); + } + + return response.status(StatusCodes.BAD_REQUEST).json({ + status: 'failed', + message: 'Unable to handle event', + }); + }) + .catch((error) => { + logger.error('Webhook middleware error:', error); + return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ + status: 'failed', + message: 'Internal server error', + }); + }); + + return; // prevent next() from being called prematurely + } + + next(); +} diff --git a/src/modules/billing/billing.controller.ts b/src/modules/billing/billing.controller.ts index ead3d87..4a0c6bc 100644 --- a/src/modules/billing/billing.controller.ts +++ b/src/modules/billing/billing.controller.ts @@ -1,12 +1,6 @@ -import { EventName, Paddle } from '@paddle/paddle-node-sdk'; -import { Request } from 'express'; -import { Body, Get, JsonController, Post, Req } from 'routing-controllers'; +import { Body, Get, JsonController, Param, Post } 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'; @@ -18,70 +12,23 @@ import { GeneratePaymentLinkDto } from './dto/generate-payment-link.dto'; @Service() @JsonController('/billing') export class BillingController { - constructor( - @Inject(diConstants.logger) private readonly logger: BonadocsLogger, - @Inject() private readonly billingService: BillingService, - @Inject() private readonly configService: ConfigService, - ) {} + constructor(@Inject() private readonly billingService: BillingService) {} @Post('/upgrade') async create( @Body({ validate: true }) payload: GeneratePaymentLinkDto, - @Req() request: Request, ): Promise> { - const authData = request.auth; - await this.billingService.upgradePlan(authData.projectId!, payload.planId); + const url = await this.billingService.upgradePlan( + payload.projectId!, + payload.planId, + payload.countryCode, + ); return { status: 'successful', message: 'Subscription plan upgraded successfully', - }; - } - - @Post('/webhook') - async webhook(@Req() request: Request): Promise | void> { - const paddleConfigs = this.configService.getTransformed('paddle'); - const paddle = new Paddle(paddleConfigs.apiKey); - const signature = request.headers['paddle-signature'] as string; - const rawBody = request.body.toString(); - const secretKey = paddleConfigs.webhookSecretKey; - try { - if (signature && rawBody) { - const eventData = await paddle.webhooks.unmarshal(rawBody, secretKey, signature); - switch (eventData.eventType) { - case EventName.TransactionPaid: - this.billingService.handleTransactionPaidWebhook(eventData.data); - this.logger.info( - `Handle transaction paid webhook event: ${eventData.notificationId} occurred at ${eventData.occurredAt}`, - ); - return { - status: 'successful', - message: 'Webhook event handled successfully', - }; - case EventName.SubscriptionCanceled: - this.billingService.handleSubscriptionCreatedWebhook(eventData.data); - this.logger.info( - `Handle subscription canceled webhook event: ${eventData.notificationId} occurred at ${eventData.occurredAt}`, - ); - return { - status: 'successful', - message: 'Webhook event handled successfully', - }; - default: - this.logger.warn(`Unhandled event type: ${eventData.eventType}`); - return { - status: 'error', - message: 'Webhook event could not be handled', - }; - } - } - } catch (error) { - this.logger.info( - `An error occurred while handling webhook event: ${error} occurred at ${new Date().toISOString()}`, - ); - } - return { - status: 'error', - message: 'Webhook event could not be handled', + data: { + url, + }, }; } @@ -95,10 +42,11 @@ export class BillingController { }; } - @Get('/active-subscription') - async getActiveSubscription(@Req() request: Request): Promise> { - const authData = request.auth; - const data = await this.billingService.getActiveSubscriptionPlan(authData.projectId!); + @Get('/active-subscription/:projectId') + async getActiveSubscription( + @Param('projectId') projectId: number, + ): Promise> { + const data = await this.billingService.getActiveSubscriptionPlan(projectId); return { status: 'successful', message: 'Plans successfully retrieved', diff --git a/src/modules/billing/billing.service.ts b/src/modules/billing/billing.service.ts index bf03d04..e2d69d4 100644 --- a/src/modules/billing/billing.service.ts +++ b/src/modules/billing/billing.service.ts @@ -1,13 +1,19 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { createHmac, timingSafeEqual } from 'crypto'; + +import { EventName } from '@paddle/paddle-node-sdk'; import { Inject, Service } from 'typedi'; import { diConstants } from '@bonadocs/di'; import { BonadocsLogger } from '@bonadocs/logger'; +import { ConfigService, Paddle as PaddleConfig } from '../configuration'; import { ApplicationError, applicationErrorCodes } from '../errors'; import { PaddleHttpClient } from '../http/paddle-http-client'; import { MailgunSender, MailTemplate } from '../mailing'; import { BillingRepository, Subscription } from '../repositories/billing'; import { PlanSummaryDto, ProjectRepository } from '../repositories/projects'; +import { InvoicePaymentStatus, InvoiceStatus } from '../shared/types/invoice'; @Service() export class BillingService { @@ -17,9 +23,10 @@ export class BillingService { @Inject() private readonly projectRepository: ProjectRepository, @Inject() private readonly paddleHttpClient: PaddleHttpClient, @Inject() private readonly mailService: MailgunSender, + @Inject() private readonly configService: ConfigService, ) {} - async upgradePlan(projectId: number, planId: number): Promise { + async upgradePlan(projectId: number, planId: number, countryCode: string): Promise { // get project const project = await this.projectRepository.getProjectById(projectId); if (project === null) { @@ -58,21 +65,28 @@ export class BillingService { }); } // get project previous subscription - const previousSubscriptionId = - await this.billingRepository.getProjectActiveSubscriptionId(projectId); - if (!previousSubscriptionId) { + const previousSubscription = + await this.billingRepository.getProjectActiveSubscription(projectId); + if (!previousSubscription) { throw new ApplicationError({ message: 'Project does not have an active subscription', logger: this.logger, errorCode: applicationErrorCodes.notFound, }); } + if (previousSubscription.planId === planId) { + throw new ApplicationError({ + message: 'User already on selected plan, please choose another plan', + logger: this.logger, + errorCode: applicationErrorCodes.invalidRequest, + }); + } const invoiceRef = this.generateInvoiceRef(); const subscriptionSetUp = await this.billingRepository.setUpSubscription( plan, projectId, { - previousSubscriptionId, + previousSubscription, }, invoiceRef, 'paddle', @@ -84,15 +98,15 @@ export class BillingService { customer: { email: adminUser!.emailAddress, name: project.name, + countryCode, }, subscription: { - priceId: plan.metadata.paddlePriceId, + priceId: plan.metadata.paddleData.priceId, customData: { invoice_id: subscriptionSetUp!.invoiceId, subscription_id: subscriptionSetUp!.subscriptionId, invoice_payment_id: subscriptionSetUp!.invoicePaymentId, bonadocs_type: 'new_subscription', - // eslint-disable-next-line @typescript-eslint/no-explicit-any } as Record, }, transaction: { @@ -105,12 +119,15 @@ export class BillingService { // update metadata await this.billingRepository.updateMetadata( subscriptionSetUp!.subscriptionId, - subscriptionSetUp!.invoiceId, + subscriptionSetUp!.invoicePaymentId, + previousSubscription.id, { paddleTransactionId: response.id, paddleTransactionUrl: response.checkout.url, }, ); + + return response.checkout.url; } async getPlans(): Promise { @@ -130,103 +147,110 @@ export class BillingService { 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, - }; - } + try { + const transactionId: string = 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); - await this.billingRepository.updateOnTransactionPaidForNewAndRecurringSubscription( - newSubscription.id, - activeSubscriptionId, - invoiceId, - 'paid', - invoicePaymentId, - 'successful', - updatedMetadata, - ); - } + if ( + (origin === 'api' && bonadocsType === 'new_subscription') || + bonadocsType === 'new_subscription' + ) { + const paddleSubscriptionId = transaction.subscription_id; + let updatedMetadata: Record | null = null; - if (origin === 'subscription_recurring') { - const paddleTransactionId = data.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, + }; + } - // 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, + InvoiceStatus.PAID, + invoicePaymentId, + InvoicePaymentStatus.SUCCESSFUL, + updatedMetadata, + ); } - 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, + InvoiceStatus.PAID, + invoicePaymentId, + InvoicePaymentStatus.SUCCESSFUL, + updatedMetadata, + ); + } - if (origin === 'subscription_charge') { - await this.billingRepository.updateTransactionPaidForSubscriptionOverage( - invoiceId, - 'paid', - invoicePaymentId, - 'successful', - ); + if (origin === 'subscription_charge') { + await this.billingRepository.updateTransactionPaidForSubscriptionOverage( + invoiceId, + InvoiceStatus.PAID, + invoicePaymentId, + InvoicePaymentStatus.SUCCESSFUL, + ); + } + } catch (error) { + this.logger.error('Error handling transaction paid webhook', error); + throw new ApplicationError({ + message: 'Error handling transaction paid webhook', + logger: this.logger, + errorCode: applicationErrorCodes.invalidTransactionData, + }); } } 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 subscriptionId = Number(transaction!.custom_data.subscription_id); const paddleSubscriptionId = data.id; let updatedMetadata: Record = {}; @@ -284,7 +308,7 @@ export class BillingService { reference: invoiceRef, amount, currency: 'USD', - status: 'unpaid', + status: InvoiceStatus.UNPAID, dateCreated: new Date(), dueDate: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // how do we determine this? }); @@ -301,7 +325,7 @@ export class BillingService { paymentChannel: 'paddle', paymentReference: invoiceRef, currency: 'USD', - status: 'unpaid', + status: InvoiceStatus.PENDING, dateCreated: new Date(), }); if (!invoicePaymentId) { @@ -320,7 +344,7 @@ export class BillingService { item: [ { price: { - product_id: plan?.metadata.paddleProductId, + product_id: plan?.metadata.paddleData.productId, description: 'Subscription Overage Charge', unit_price: { amount, @@ -381,6 +405,97 @@ export class BillingService { }); } + async handleEvent(eventData: Record): Promise { + switch (eventData.event_type) { + case EventName.TransactionPaid: + this.handleTransactionPaidWebhook(eventData.data); + this.logger.info( + `Handle transaction paid webhook event: ${eventData.notificationId} occurred at ${eventData.occurredAt}`, + ); + return true; + case EventName.SubscriptionCreated: + this.handleSubscriptionCreatedWebhook(eventData.data); + this.logger.info( + `Handle subscription canceled webhook event: ${eventData.notificationId} occurred at ${eventData.occurredAt}`, + ); + return true; + default: + this.logger.warn(`Unhandled event type: ${eventData.eventType}`); + return true; + } + } + + async verifyWebhookSignature( + request: Record, + ): Promise> | null> { + const paddleConfigs = this.configService.getTransformed('paddle'); + + if (!Buffer.isBuffer(request.body)) { + return request.status(500).json({ error: 'Server misconfigured' }); + } + + // 1. Get Paddle-Signature header + const paddleSignature = request.headers['paddle-signature'] as string; + const secretKey = paddleConfigs.webhookSecretKey; + + // (Optional) Check if header and secret key are present and return error if not + if (!paddleSignature) { + return request.status(400).json({ error: 'Invalid request' }); + } + + if (!secretKey) { + return request.status(500).json({ error: 'Server misconfigured' }); + } + + // 2. Extract timestamp and signature from header + if (!paddleSignature || !paddleSignature.includes(';')) { + return request.status(400).json({ error: 'Invalid request' }); + } + + const parts = paddleSignature.split(';'); + + if (parts.length !== 2) { + return request.status(400).json({ error: 'Invalid request' }); + } + const [timestampPart, signaturePart] = parts.map((part) => part.split('=')[1]); + + if (!timestampPart || !signaturePart) { + return request.status(400).json({ error: 'Invalid request' }); + } + + const timestamp = timestampPart; + const signature = signaturePart; + + // (Optional) Check timestamp against current time and reject if it's over 5 seconds old + const timestampInt = parseInt(timestamp, 10) * 1000; + + // eslint-disable-next-line no-restricted-globals + if (isNaN(timestampInt)) { + return request.status(400).json({ error: 'Invalid request' }); + } + + const currentTime = Date.now(); + + if (currentTime - timestampInt > 50000) { + return request.status(408).json({ error: 'Event expired' }); + } + + // 3. Build signed payload + const bodyRaw = request.body.toString(); // Converts from buffer to string + const signedPayload = `${timestamp}:${bodyRaw}`; + + // 4. Hash signed payload using HMAC SHA256 and the secret key + const hashedPayload = createHmac('sha256', secretKey) + .update(signedPayload, 'utf8') + .digest('hex'); + + // 5. Compare signatures + if (!timingSafeEqual(Buffer.from(hashedPayload), Buffer.from(signature))) { + return request.status(401).json({ error: 'Invalid signature' }); + } + return this.bufferToEventyEntity(request.body); + } + generateInvoiceRef(): string { const now = new Date(); const pad2 = (n: number) => n.toString().padStart(2, '0'); @@ -394,4 +509,10 @@ export class BillingService { const timestamp = `${year}${month}${day}${hour}${minute}${second}${milli}`; return `INV-${timestamp}`; } + + private bufferToEventyEntity(buffer: Buffer): Record { + const decoder = new TextDecoder('utf-8'); + const jsonString = decoder.decode(new Uint8Array(buffer)); + return JSON.parse(jsonString) as Record; + } } diff --git a/src/modules/billing/dto/generate-payment-link.dto.ts b/src/modules/billing/dto/generate-payment-link.dto.ts index 3779469..8703da9 100644 --- a/src/modules/billing/dto/generate-payment-link.dto.ts +++ b/src/modules/billing/dto/generate-payment-link.dto.ts @@ -5,4 +5,14 @@ export class GeneratePaymentLinkDto { message: 'Subscription plan not provided', }) planId: number; + + @IsNotEmpty({ + message: 'Project Id not provided', + }) + projectId: number; + + @IsNotEmpty({ + message: 'Country code not provided', + }) + countryCode: string; } diff --git a/src/modules/configuration/config.interface.ts b/src/modules/configuration/config.interface.ts index 37a49aa..c47dba9 100644 --- a/src/modules/configuration/config.interface.ts +++ b/src/modules/configuration/config.interface.ts @@ -28,6 +28,8 @@ export type ServiceConfiguration = { paddle: { secretKey: string; baseUrl: string; + apiKey: string; + webhookSecretKey: string; }; }; diff --git a/src/modules/http/paddle-http-client/client.ts b/src/modules/http/paddle-http-client/client.ts index 59af600..f94c76c 100644 --- a/src/modules/http/paddle-http-client/client.ts +++ b/src/modules/http/paddle-http-client/client.ts @@ -9,6 +9,7 @@ import { ApplicationError } from '../../errors'; import { BaseHttpClient } from '../base'; import { + AddressResponse, CreateOneTimeChargeForOveragesRequest, CreateOneTimeChargeForOveragesResponse, CreateTransactionRequest, @@ -45,9 +46,14 @@ export class PaddleHttpClient extends BaseHttpClient { if (!customer) { customer = await this.createCustomer(request.customer.name, request.customer.email); } - + let address: AddressResponse | undefined; + const addresses = await this.listCustomerAddress(customer.id); + address = addresses?.find((x) => x.country_code === request.customer.countryCode); + if (address === null || address === undefined) { + address = await this.createCustomerAddress(customer.id, request.customer.countryCode); + } // create transaction - const response = await this.request({ + const response = await this.request>({ url: '/transactions', method: 'POST', data: { @@ -58,21 +64,10 @@ export class PaddleHttpClient extends BaseHttpClient { }, ], customer_id: customer.id, - collection_mode: 'manual', + address_id: address.id, + collection_mode: 'automatic', currency_code: 'USD', - billing_details: { - enable_checkout: true, - payment_terms: { - interval: 'month', - frequency: 30, - purchase_order_number: request.transaction.invoiceNumber, - }, - }, custom_data: request.subscription.customData, - billing_period: { - starts_at: request.transaction.startsAt, - ends_at: request.transaction.endsAt, - }, }, }); @@ -86,7 +81,7 @@ export class PaddleHttpClient extends BaseHttpClient { }); } - return response.data; + return response.data.data as TransactionResponse; } // one-time charge on subscription @@ -94,7 +89,7 @@ export class PaddleHttpClient extends BaseHttpClient { subscriptionId: number, request: CreateOneTimeChargeForOveragesRequest, ): Promise { - const response = await this.request({ + const response = await this.request>({ url: `/subscriptions/${subscriptionId}/charge`, method: 'POST', data: request, @@ -108,12 +103,12 @@ export class PaddleHttpClient extends BaseHttpClient { userFriendlyMessage: 'Error creating customer', }); } - return response.data; + return response.data.data as CreateOneTimeChargeForOveragesResponse; } // get transactions by subscription id async getTransactionsBySubscriptionId(subscriptionId: string): Promise { - const response = await this.request({ + const response = await this.request>({ url: `/transactions?subscription_id=${subscriptionId}`, method: 'GET', }); @@ -126,12 +121,12 @@ export class PaddleHttpClient extends BaseHttpClient { userFriendlyMessage: 'Error getting transactions', }); } - return response.data; + return response.data.data as TransactionResponse[]; } // get transactions by id async getTransactionById(transactionId: string): Promise { - const response = await this.request({ + const response = await this.request>({ url: `/transactions/${transactionId}`, method: 'GET', }); @@ -144,11 +139,11 @@ export class PaddleHttpClient extends BaseHttpClient { userFriendlyMessage: 'Error getting transactions', }); } - return response.data; + return response.data.data as TransactionResponse; } async getTransactions(transactionIds: string): Promise { - const response = await this.request({ + const response = await this.request>({ url: `/transactions?id=${transactionIds}`, method: 'GET', }); @@ -161,12 +156,12 @@ export class PaddleHttpClient extends BaseHttpClient { userFriendlyMessage: 'Error getting transactions', }); } - return response.data; + return response.data.data as TransactionResponse[]; } // create customer private async createCustomer(fullName: string, email: string): Promise { - const response = await this.request({ + const response = await this.request>({ url: '/customers', method: 'POST', data: { @@ -184,12 +179,12 @@ export class PaddleHttpClient extends BaseHttpClient { userFriendlyMessage: 'Error creating customer', }); } - return response.data; + return response.data.data as CustomerResponse; } // get customer - private async getCustomer(email: string): Promise { - const response = await this.request>({ + private async getCustomer(email: string): Promise | undefined> { + const response = await this.request>({ url: '/customers', method: 'GET', params: { @@ -207,8 +202,66 @@ export class PaddleHttpClient extends BaseHttpClient { }); } - const customer = response.data.find((item) => item.email === email); + const result = response.data.data as Record[]; + + if (result.length === 0) { + return undefined; + } + + const customer = result.find((item) => item.email === email); + + return customer as CustomerResponse; + } + + // list customer address + private async listCustomerAddress(customerId: string): Promise { + const response = await this.request>({ + url: `customers/${customerId}/addresses`, + method: 'GET', + }); + + if (response.status !== 200) { + this.logger.error('Error getting customer', response); + throw new ApplicationError({ + logger: this.logger, + message: 'Error getting paddle customer address', + errorCode: 'paddle_customer_get_error', + userFriendlyMessage: 'Error getting customer', + }); + } + + const result = response.data.data as Record[]; + + if (result.length === 0) { + return undefined; + } + + return result as AddressResponse[]; + } + + // create customers address + private async createCustomerAddress( + customerId: string, + countryCode: string, + ): Promise { + const response = await this.request>({ + url: `customers/${customerId}/addresses`, + method: 'POST', + data: { + country_code: countryCode, + }, + }); + + if (response.status !== 201) { + this.logger.error('Error getting customer', response); + throw new ApplicationError({ + logger: this.logger, + message: 'Error creating paddle customer address', + errorCode: 'paddle_customer_get_error', + userFriendlyMessage: 'Error getting customer', + }); + } - return customer; + return response.data.data as AddressResponse; } } diff --git a/src/modules/http/paddle-http-client/types.ts b/src/modules/http/paddle-http-client/types.ts index badfb27..8be265c 100644 --- a/src/modules/http/paddle-http-client/types.ts +++ b/src/modules/http/paddle-http-client/types.ts @@ -26,12 +26,14 @@ export type TransactionResponse = { url: string; }; custom_data: Record; + subscription_id: string; }; export type CreateTransactionRequest = { customer: { email: string; name: string; + countryCode: string; }; subscription: { priceId: string; @@ -53,3 +55,8 @@ export type CreateOneTimeChargeForOveragesRequest = { export type CreateOneTimeChargeForOveragesResponse = { id: string; }; + +export type AddressResponse = { + id: string; + country_code: string; +}; diff --git a/src/modules/repositories/billing/billing.repository.ts b/src/modules/repositories/billing/billing.repository.ts index eec6886..fcf2323 100644 --- a/src/modules/repositories/billing/billing.repository.ts +++ b/src/modules/repositories/billing/billing.repository.ts @@ -4,6 +4,7 @@ import { diConstants } from '@bonadocs/di'; import { BonadocsLogger } from '@bonadocs/logger'; import { DbContext, withDbContext } from '../../connection/dbcontext'; +import { InvoicePaymentStatus, InvoiceStatus } from '../../shared/types/invoice'; import { SubscriptionStatus } from '../../shared/types/subscriptions'; import { PlanResourceDetails, PlanSummaryDto, PlanWithMetadataDto } from '../projects'; @@ -328,6 +329,7 @@ export class BillingRepository { async updateMetadata( subscriptionId: number, invoicePaymentId: number, + previousSubscriptionId: number, metadata: Record, context?: DbContext, ): Promise { @@ -335,7 +337,13 @@ export class BillingRepository { context?.beginTransaction(); await context?.query({ text: queries.updateSubscriptionMetadata, - values: [subscriptionId, metadata], + values: [ + subscriptionId, + { + ...metadata, + previousSubscriptionId, + }, + ], validateResult: (result) => !!result.rowCount, validationErrorMessage: 'Failed to update subscription metadata', }); @@ -382,9 +390,9 @@ export class BillingRepository { newSubscriptionId: number | null, activeSubscriptionId: number, invoiceId: number, - invoiceStatus: string, + invoiceStatus: InvoiceStatus, invoicePaymentId: number, - invoicePaymentStatus: string, + invoicePaymentStatus: InvoicePaymentStatus, metadata: Record | null, context?: DbContext, ): Promise { @@ -436,9 +444,9 @@ export class BillingRepository { @withDbContext async updateTransactionPaidForSubscriptionOverage( invoiceId: number, - invoiceStatus: string, + invoiceStatus: InvoiceStatus, invoicePaymentId: number, - invoicePaymentStatus: string, + invoicePaymentStatus: InvoicePaymentStatus, context?: DbContext, ): Promise { try { @@ -519,29 +527,57 @@ export class BillingRepository { reference, amount: plan.price, currency: plan.currency, - status: 'unpaid', + 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 invoiceId = await this.createInvoice(invoice, context!); - - // create subscription invoice payment + const createInvoiceResult = await context?.query({ + text: queries.createInvoice, + values: [ + invoice.subscriptionId, + invoice.reference, + invoice.amount, + invoice.currency, + invoice.status, + invoice.dateCreated, + invoice.dueDate, + ], + validateResult: (result) => !!result.rowCount, + validationErrorMessage: 'Failed to create invoice', + }); + const invoiceId = createInvoiceResult?.rows[0].id; const invoicePayment: InvoicePayment = { invoiceId: invoiceId!, paymentChannel, paymentReference, amount: plan.price, currency: plan.currency, - status: 'pending', + status: InvoiceStatus.PENDING, dateCreated: new Date(), }; - const invoicePaymentId = await this.createInvoicePayment(invoicePayment, context!); + + const createInvoicePaymentResult = await context?.query({ + text: queries.createInvoicePayment, + values: [ + invoicePayment.invoiceId, + invoicePayment.paymentChannel, + invoicePayment.paymentReference, + invoicePayment.amount, + invoicePayment.currency, + invoicePayment.dateCreated, + invoicePayment.status, + ], + validateResult: (result) => !!result.rowCount, + validationErrorMessage: 'Failed to create invoice payment', + }); + + const createInvoicePaymentId = createInvoicePaymentResult?.rows[0].id; context!.commitTransaction(); return { invoiceId: invoiceId!, - invoicePaymentId: invoicePaymentId!, + invoicePaymentId: createInvoicePaymentId!, subscriptionId, }; } catch (error) { @@ -612,7 +648,7 @@ export class BillingRepository { ): Promise { const getProjectActiveSubscriptionResult = await context?.query({ text: queries.getProjectActiveSubscriptionId, - values: [projectId], + values: [projectId, SubscriptionStatus.Active], validateResult: (result) => !!result.rowCount, validationErrorMessage: 'Failed to get project active subscription', }); @@ -629,7 +665,7 @@ export class BillingRepository { ): Promise { const getProjectActiveSubscriptionResult = await context?.query({ text: queries.getProjectActiveSubscription, - values: [projectId], + values: [projectId, SubscriptionStatus.Active], validateResult: (result) => !!result.rowCount, validationErrorMessage: 'Failed to get project active subscription', }); diff --git a/src/modules/repositories/billing/queries.ts b/src/modules/repositories/billing/queries.ts index 3c5c026..1582280 100644 --- a/src/modules/repositories/billing/queries.ts +++ b/src/modules/repositories/billing/queries.ts @@ -2,13 +2,13 @@ export const queries = { createInvoice: 'INSERT INTO "bonadocs"."invoices" (subscription_id, reference, amount, currency, status, date_created, due_date) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id;', createInvoicePayment: - 'INSERT INTO "bonadocs"."invoice_payments" (invoice_id, payment_channel, paymnet_reference, amount, currency, date_created, status) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id;', + 'INSERT INTO "bonadocs"."invoice_payments" (invoice_id, payment_channel, payment_reference, amount, currency, date_created, status) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id;', createInvoiceItem: 'INSERT INTO "bonadocs"."invoice_items" (invoice_id, resource_id, quantity, amount, currency) VALUES ($1, $2, $3, $4, $5) RETURNING id;', - updateInvoiceStatus: 'UPDATE "bonadocs"."invoices" SET status = $1 WHERE id = $2', + 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', - updateInvoicePaymentStatus: 'UPDATE "bonadocs"."invoice_payments" SET status = $1 WHERE id = $2', + 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', updateInvoicePayment: @@ -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 p.id, p.name, p.price, p.currency, p.duration, p.active, p.date_created, FROM "bonadocs"."plans" AS p', + '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', @@ -26,19 +26,19 @@ export const queries = { 'UPDATE "bonadocs"."invoice_payments" SET metadata = $2 WHERE id = $1', createProjectSubscription: 'INSERT INTO "bonadocs"."subscriptions" (project_id, plan_id, metadata, status, date_expires) VALUES ($1, $2, $3, $4, $5) RETURNING id', - updateSubscriptionStatus: 'UPDATE "bonadocs"."subscriptions" SET status = $1 WHERE id = $3', + updateSubscriptionStatus: 'UPDATE "bonadocs"."subscriptions" SET status = $2 WHERE id = $1', insertSubscriptionResources: 'INSERT INTO "bonadocs"."subscription_resources" (subscription_id, resource_id, quantity, usage, overages_supported, overages_price, overages_bundle_count) VALUES ($1, $2, $3, $4, $5, $6, $7)', getPlanResources: 'SELECT id, plan_id, resource_id, quantity, overages_supported, overages_price, overages_bundle_count FROM "bonadocs"."plan_resources" WHERE plan_id = $1', getProjectActiveSubscriptionId: - 'SELECT id FROM "bonadocs"."subscriptions" WHERE project_id = $1 AND status = \'active\' LIMIT 1', + '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 = \'active\' LIMIT 1', + 'SELECT id, project_id, plan_id, metadata, status, date_expires FROM "bonadocs"."subscriptions" WHERE project_id = $1 AND status = $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 project_id = $1 LIMIT 1', + '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', getAlmostDueSubscriptions: ` diff --git a/src/modules/repositories/billing/types.ts b/src/modules/repositories/billing/types.ts index 30c8f79..017df77 100644 --- a/src/modules/repositories/billing/types.ts +++ b/src/modules/repositories/billing/types.ts @@ -1,3 +1,4 @@ +import { InvoiceStatus } from '../../shared/types/invoice'; import { SubscriptionStatus } from '../../shared/types/subscriptions'; export interface Invoice { @@ -5,7 +6,7 @@ export interface Invoice { reference: string; amount: number; currency: string; - status: string; + status: InvoiceStatus; dateCreated: Date; dueDate: Date; } @@ -16,7 +17,7 @@ export interface InvoicePayment { paymentReference: string; amount: number; currency: string; - status: string; + status: InvoiceStatus; dateCreated: Date; } diff --git a/src/modules/shared/types/invoice.ts b/src/modules/shared/types/invoice.ts new file mode 100644 index 0000000..b8b4b76 --- /dev/null +++ b/src/modules/shared/types/invoice.ts @@ -0,0 +1,12 @@ +export enum InvoiceStatus { + PENDING = 1, + PAID = 2, + CANCELED = 3, + UNPAID = 4, +} + +export enum InvoicePaymentStatus { + PENDING = 1, + SUCCESSFUL = 2, + UNSUCCESSFUL = 3, +}