From 6e1ca728a820d1bba8c870c60e849f96c0685d8e Mon Sep 17 00:00:00 2001 From: DaevMithran <61043607+DaevMithran@users.noreply.github.com> Date: Fri, 6 Sep 2024 12:32:16 +0530 Subject: [PATCH] fix: Fix customer creation workflow [DEV-4287] (#574) * fix: Fix customer creation workflow * fix swagger * Generate migrations * Add error handler for webhooks * fix subscription create workflow * Remove subscription-submitter * Improve error handling * fix: Account create validator * Remove duplicate stripe initialization * fix issues --------- Co-authored-by: Ankur Banerjee --- src/controllers/admin/webhook.ts | 413 +++++++++++++++--- src/controllers/api/account.ts | 46 +- src/database/entities/customer.entity.ts | 9 +- .../AlterCustomerTableUniqueEmail.ts | 45 ++ src/database/migrations/MigrateData.ts | 2 +- src/database/types/types.ts | 3 + src/services/admin/stripe.ts | 30 +- src/services/api/customer.ts | 9 +- .../track/admin/subscription-submitter.ts | 322 -------------- src/services/track/helpers.ts | 26 +- src/services/track/tracker.ts | 2 - src/static/swagger-api.json | 6 + src/types/swagger-api-types.ts | 4 + 13 files changed, 466 insertions(+), 451 deletions(-) create mode 100644 src/database/migrations/AlterCustomerTableUniqueEmail.ts delete mode 100644 src/services/track/admin/subscription-submitter.ts diff --git a/src/controllers/admin/webhook.ts b/src/controllers/admin/webhook.ts index 4b205fae5..2cc726b01 100644 --- a/src/controllers/admin/webhook.ts +++ b/src/controllers/admin/webhook.ts @@ -1,21 +1,27 @@ -import Stripe from 'stripe'; +import type Stripe from 'stripe'; import type { Request, Response } from 'express'; import * as dotenv from 'dotenv'; import { StatusCodes } from 'http-status-codes'; import { EventTracker, eventTracker } from '../../services/track/tracker.js'; import type { INotifyMessage } from '../../types/track.js'; import { OperationNameEnum } from '../../types/constants.js'; -import { buildSubmitOperation } from '../../services/track/helpers.js'; +import { SubscriptionService } from '../../services/admin/subscription.js'; +import { CustomerService } from '../../services/api/customer.js'; +import { LogToHelper } from '../../middleware/auth/logto-helper.js'; +import { UserService } from '../../services/api/user.js'; +import type { SupportedPlanTypes } from '../../types/admin.js'; +import type { CustomerEntity } from '../../database/entities/customer.entity.js'; +import { buildSubscriptionData } from '../../services/track/helpers.js'; dotenv.config(); export class WebhookController { + public static instance = new WebhookController(); + public async handleWebhook(request: Request, response: Response) { // Signature verification and webhook handling is placed in the same method // cause stripe uses the mthod which validate the signature and provides the event. let event = request.body; - let subscription; - let status; - const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); + const stripe = response.locals.stripe as Stripe; if (!process.env.STRIPE_WEBHOOK_SECRET) { await eventTracker.notify({ @@ -43,69 +49,348 @@ export class WebhookController { } satisfies INotifyMessage); return response.sendStatus(StatusCodes.BAD_REQUEST); } - // Handle the event - switch (event.type) { - case 'customer.subscription.trial_will_end': - subscription = event.data.object; - status = subscription.status; - await eventTracker.notify({ - message: EventTracker.compileBasicNotification( - `Subscription status is ${status} for subscription with id: ${subscription.id}`, - 'Stripe Webhook: customer.subscription.trial_will_end' - ), - severity: 'info', - } satisfies INotifyMessage); - await eventTracker.submit( - buildSubmitOperation(subscription, OperationNameEnum.SUBSCRIPTION_TRIAL_WILL_END) + + try { + // Handle the event + switch (event.type) { + case 'customer.subscription.deleted': + await WebhookController.instance.handleSubscriptionCancel(stripe, event.data.object); + break; + case 'customer.subscription.created': + await WebhookController.instance.handleSubscriptionCreate(stripe, event.data.object); + break; + case 'customer.subscription.updated': + await WebhookController.instance.handleSubscriptionUpdate(stripe, event.data.object); + break; + default: + // Unexpected event type + await eventTracker.notify({ + message: EventTracker.compileBasicNotification( + `Unexpected event: ${event} with type: ${event?.type}`, + 'Stripe Webhook: unexpected' + ), + severity: 'error', + } satisfies INotifyMessage); + } + // Return a 200 response to acknowledge receipt of the event + return response.status(StatusCodes.OK).send(); + } catch (error) { + // Unexpected event type + await eventTracker.notify({ + message: EventTracker.compileBasicNotification( + `Webhook failed: ${(error as Error)?.message || error} with type: ${event?.type}`, + 'Stripe Webhook: unexpected' + ), + severity: 'error', + } satisfies INotifyMessage); + + return response.status(StatusCodes.INTERNAL_SERVER_ERROR).json({ + error: `Internal error: ${(error as Error)?.message || error}`, + }); + } + } + + async handleSubscriptionCreate( + stripe: Stripe, + stripeSubscription: Stripe.Subscription, + customer?: CustomerEntity + ): Promise { + const data = buildSubscriptionData(stripeSubscription); + const operation = OperationNameEnum.SUBSCRIPTION_CREATE; + + await eventTracker.notify({ + message: EventTracker.compileBasicNotification( + `Subscription status is ${stripeSubscription.status} for subscription with id: ${stripeSubscription.id}`, + 'Stripe Webhook: customer.subscription.created' + ), + severity: 'info', + } satisfies INotifyMessage); + + console.log('fetching stripe data'); + const [product, stripeCustomer] = await Promise.all([ + stripe.products.retrieve(data.productId), + stripe.customers.retrieve(data.paymentProviderId), + ]); + if (!customer) { + const customers = await CustomerService.instance.customerRepository.find({ + where: { paymentProviderId: data.paymentProviderId }, + }); + if (customers.length === 0) { + // we add an additional check in case that a customer was created locally with email and no paymentProviderId + if (!stripeCustomer.deleted && stripeCustomer.email) { + const customerWithoutPaymentProviderId = await CustomerService.instance.customerRepository.findOne({ + where: { email: stripeCustomer.email }, + }); + + if (!customerWithoutPaymentProviderId) { + await eventTracker.notify({ + message: EventTracker.compileBasicNotification( + `Customer not found for Cheqd Studio, creating new customer record with paymentProviderId: ${data.paymentProviderId}`, + operation + ), + severity: 'info', + }); + + const customerName = stripeCustomer.name ?? stripeCustomer.email; + customers.push( + await CustomerService.instance.create( + customerName, + stripeCustomer.email, + undefined, + data.paymentProviderId + ) + ); + } else { + customers.push(customerWithoutPaymentProviderId); + } + } else { + const message = EventTracker.compileBasicNotification( + `Customer not found for Cheqd Studio, cannot create new customer without a email id: ${data.paymentProviderId}`, + operation + ); + await eventTracker.notify({ + message, + severity: 'error', + }); + throw new Error(message); + } + } else if (customers.length !== 1) { + const message = EventTracker.compileBasicNotification( + `Only one Stripe account should be associated with CaaS customer. Stripe accountId: ${data.paymentProviderId}.`, + operation ); - break; - case 'customer.subscription.deleted': - subscription = event.data.object; - status = subscription.status; - await eventTracker.notify({ - message: EventTracker.compileBasicNotification( - `Subscription status is ${status} for subscription with id: ${subscription.id}`, - 'Stripe Webhook: customer.subscription.deleted' - ), - severity: 'info', - } satisfies INotifyMessage); - await eventTracker.submit(buildSubmitOperation(subscription, OperationNameEnum.SUBSCRIPTION_CANCEL)); - break; - case 'customer.subscription.created': - subscription = event.data.object; - status = subscription.status; await eventTracker.notify({ - message: EventTracker.compileBasicNotification( - `Subscription status is ${status} for subscription with id: ${subscription.id}`, - 'Stripe Webhook: customer.subscription.created' - ), - severity: 'info', - } satisfies INotifyMessage); - await eventTracker.submit(buildSubmitOperation(subscription, OperationNameEnum.SUBSCRIPTION_CREATE)); - break; - case 'customer.subscription.updated': - subscription = event.data.object; - status = subscription.status; - await eventTracker.notify({ - message: EventTracker.compileBasicNotification( - `Subscription status is ${status} for subscription with id: ${subscription.id}`, - 'Stripe Webhook: customer.subscription.updated' - ), - severity: 'info', - } satisfies INotifyMessage); - await eventTracker.submit(buildSubmitOperation(subscription, OperationNameEnum.SUBSCRIPTION_UPDATE)); - break; - default: - // Unexpected event type + message, + severity: 'error', + }); + throw new Error(message); + } + + customer = customers[0]; + } + + const subscription = await SubscriptionService.instance.create( + data.subscriptionId, + customer, + data.status, + data.currentPeriodStart, + data.currentPeriodEnd, + data.trialStart as Date, + data.trialEnd as Date + ); + if (!subscription) { + const message = EventTracker.compileBasicNotification( + `Failed to create a new subscription with id: ${data.subscriptionId}.`, + operation + ); + await eventTracker.notify({ + message, + severity: 'error', + }); + throw new Error(message); + } + + await this.syncLogtoRoles(operation, customer.customerId, product.name); + + await eventTracker.notify({ + message: EventTracker.compileBasicNotification( + `Subscription created with id: ${data.subscriptionId}.`, + operation + ), + severity: 'info', + }); + } + + async handleSubscriptionUpdate(stripe: Stripe, stripeSubscription: Stripe.Subscription): Promise { + const data = buildSubscriptionData(stripeSubscription); + const operation = OperationNameEnum.SUBSCRIPTION_UPDATE; + + await eventTracker.notify({ + message: EventTracker.compileBasicNotification( + `Subscription status is ${stripeSubscription.status} for subscription with id: ${stripeSubscription.id}`, + 'Stripe Webhook: customer.subscription.updated' + ), + severity: 'info', + } satisfies INotifyMessage); + + const subscription = await SubscriptionService.instance.update( + data.subscriptionId, + data.status, + data.currentPeriodStart, + data.currentPeriodEnd, + data.trialStart as Date, + data.trialEnd as Date + ); + if (!subscription) { + const message = EventTracker.compileBasicNotification( + `Failed to update subscription with id: ${data.subscriptionId}.`, + operation + ); + await eventTracker.notify({ + message, + severity: 'error', + }); + + throw new Error(message); + } + + const [customer, product] = await Promise.all([ + CustomerService.instance.findbyPaymentProviderId(data.paymentProviderId), + stripe.products.retrieve(data.productId), + ]); + + if (customer) { + await this.syncLogtoRoles(operation, customer.customerId, product.name); + } + + await eventTracker.notify({ + message: EventTracker.compileBasicNotification( + `Subscription updated with id: ${data.subscriptionId}.`, + operation + ), + severity: 'info', + }); + } + + async handleSubscriptionCancel(stripe: Stripe, stripeSubscription: Stripe.Subscription): Promise { + const data = buildSubscriptionData(stripeSubscription); + const operation = OperationNameEnum.SUBSCRIPTION_CANCEL; + + await eventTracker.notify({ + message: EventTracker.compileBasicNotification( + `Subscription status is ${stripeSubscription.status} for subscription with id: ${stripeSubscription.id}`, + 'Stripe Webhook: customer.subscription.deleted' + ), + severity: 'info', + } satisfies INotifyMessage); + + const subscription = await SubscriptionService.instance.update(data.subscriptionId, data.status); + if (!subscription) { + const message = EventTracker.compileBasicNotification( + `Failed to cancel subscription with id: ${data.subscriptionId}.`, + operation + ); + await eventTracker.notify({ + message, + severity: 'error', + }); + + throw new Error(message); + } + + const customer = await CustomerService.instance.findbyPaymentProviderId(data.paymentProviderId); + if (customer) { + this.syncLogtoRoles(operation, customer.customerId, ''); + } + + await eventTracker.notify({ + message: EventTracker.compileBasicNotification( + `Subscription canceled with id: ${data.subscriptionId}.`, + operation + ), + severity: 'info', + }); + } + + private async handleCustomerRoleAssignment( + operation: OperationNameEnum, + logToHelper: LogToHelper, + userLogtoId: string, + productName: string + ) { + const roleAssignmentResponse = await logToHelper.assignCustomerPlanRoles( + userLogtoId, + productName.toLowerCase() as SupportedPlanTypes + ); + if (roleAssignmentResponse.status !== 201) { + const message = EventTracker.compileBasicNotification( + `Failed to assign roles to user for planType ${productName}: ${roleAssignmentResponse.error}`, + operation + ); + await eventTracker.notify({ + message, + severity: 'error', + }); + throw new Error(message); + } + + await eventTracker.notify({ + message: EventTracker.compileBasicNotification( + `${productName} plan assigned to user with logtoId ${userLogtoId}`, + operation + ), + severity: 'info', + }); + } + + private async handleCustomerRoleRemoval(operation: OperationNameEnum, logto: LogToHelper, userLogtoId: string) { + const responses = await Promise.allSettled([ + logto.removeLogtoRoleFromUser(userLogtoId, process.env.LOGTO_TESTNET_ROLE_ID.trim()), + logto.removeLogtoRoleFromUser(userLogtoId, process.env.LOGTO_MAINNET_ROLE_ID.trim()), + ]); + + const allRolesRemoved = responses.every((r) => r.status === 'fulfilled' && r.value.status === StatusCodes.OK); + if (allRolesRemoved) { + await eventTracker.notify({ + message: EventTracker.compileBasicNotification( + `Roles have been removed successfully for user with id: ${userLogtoId}`, + operation + ), + severity: 'info', + }); + return; + } + + for (const resp of responses) { + if (resp.status === 'rejected' || resp.value.status !== StatusCodes.OK) { + const errMsg = resp.status === 'rejected' ? (resp.reason as Error).message : resp.value.error; await eventTracker.notify({ - message: EventTracker.compileBasicNotification( - `Unexpected event: ${event} with type: ${event?.type}`, - 'Stripe Webhook: unexpected' - ), + message: EventTracker.compileBasicNotification(`Role removal error: ${errMsg}`, operation), severity: 'error', - } satisfies INotifyMessage); + }); + throw new Error(errMsg); + } } - // Return a 200 response to acknowledge receipt of the event - return response.status(StatusCodes.OK).send(); + } + + private async syncLogtoRoles(operation: OperationNameEnum, customerId: string, productName: string) { + const logToHelper = new LogToHelper(); + const setupResp = await logToHelper.setup(); + if (setupResp.status !== StatusCodes.OK) { + const message = EventTracker.compileBasicNotification( + `Logto client initialisation failed: ${setupResp.error}`, + operation + ); + await eventTracker.notify({ + message, + severity: 'error', + }); + + throw new Error(message); + } + + const user = await UserService.instance.userRepository.findOne({ where: { customer: { customerId } } }); + + if (user) { + switch (operation) { + case OperationNameEnum.SUBSCRIPTION_CREATE: + case OperationNameEnum.SUBSCRIPTION_UPDATE: + this.handleCustomerRoleAssignment(operation, logToHelper, user.logToId, productName); + return; + case OperationNameEnum.SUBSCRIPTION_CANCEL: + this.handleCustomerRoleRemoval(operation, logToHelper, user.logToId); + return; + } + } + + const message = EventTracker.compileBasicNotification( + `Role assignment failed: No user found with customerId: ${customerId}`, + operation + ); + await eventTracker.notify({ + message, + severity: 'error', + }); + throw new Error(message); } } diff --git a/src/controllers/api/account.ts b/src/controllers/api/account.ts index d2a2a0743..996619d2b 100644 --- a/src/controllers/api/account.ts +++ b/src/controllers/api/account.ts @@ -1,4 +1,19 @@ import type { Request, Response } from 'express'; +import type { CustomerEntity } from '../../database/entities/customer.entity.js'; +import type { PaymentAccountEntity } from '../../database/entities/payment.account.entity.js'; +import type { + QueryCustomerResponseBody, + QueryIdTokenResponseBody, + UnsuccessfulQueryCustomerResponseBody, + UnsuccessfulQueryIdTokenResponseBody, +} from '../../types/customer.js'; +import type { UnsuccessfulResponseBody } from '../../types/shared.js'; +import type { ISubmitOperation, ISubmitStripeCustomerCreateData } from '../../services/track/submitter.js'; +import type Stripe from 'stripe'; +import type { SafeAPIResponse } from '../../types/common.js'; +import type { RoleEntity } from '../../database/entities/role.entity.js'; +import type { SupportedPlanTypes } from '../../types/admin.js'; + import { CheqdNetwork, checkBalance } from '@cheqd/sdk'; import { TESTNET_MINIMUM_BALANCE, DEFAULT_DENOM_EXPONENT, OperationNameEnum } from '../../types/constants.js'; import { CustomerService } from '../../services/api/customer.js'; @@ -8,39 +23,28 @@ import { StatusCodes } from 'http-status-codes'; import { LogToWebHook } from '../../middleware/hook.js'; import { UserService } from '../../services/api/user.js'; import { PaymentAccountService } from '../../services/api/payment-account.js'; -import type { CustomerEntity } from '../../database/entities/customer.entity.js'; -import type { PaymentAccountEntity } from '../../database/entities/payment.account.entity.js'; import { IdentityServiceStrategySetup } from '../../services/identity/index.js'; -import type { - QueryCustomerResponseBody, - QueryIdTokenResponseBody, - UnsuccessfulQueryCustomerResponseBody, - UnsuccessfulQueryIdTokenResponseBody, -} from '../../types/customer.js'; -import type { UnsuccessfulResponseBody } from '../../types/shared.js'; import { check } from 'express-validator'; import { EventTracker, eventTracker } from '../../services/track/tracker.js'; -import type { ISubmitOperation, ISubmitStripeCustomerCreateData } from '../../services/track/submitter.js'; import * as dotenv from 'dotenv'; import { validate } from '../validator/decorator.js'; import { SupportedKeyTypes } from '@veramo/utils'; import { SubscriptionService } from '../../services/admin/subscription.js'; -import Stripe from 'stripe'; import { RoleService } from '../../services/api/role.js'; -import { SafeAPIResponse } from '../../types/common.js'; -import { RoleEntity } from '../../database/entities/role.entity.js'; import { getStripeObjectKey } from '../../utils/index.js'; -import type { SupportedPlanTypes } from '../../types/admin.js'; import { KeyService } from '../../services/api/key.js'; dotenv.config(); export class AccountController { public static createValidator = [ - check('username') + check('primaryEmail') .exists() - .withMessage('username is required') - .isString() - .withMessage('username should be a unique valid string'), + .withMessage('primaryEmail is required') + .bail() + .isEmail() + .withMessage('Invalid email id') + .bail(), + check('name').optional().isString().withMessage('name should be a valid string'), ]; /** * @openapi @@ -255,7 +259,7 @@ export class AccountController { //4. Check if there is customer associated with such user if (!userEntity.customer) { - customerEntity = (await CustomerService.instance.create(logToUserEmail)) as CustomerEntity; + customerEntity = (await CustomerService.instance.create(logToName, logToUserEmail)) as CustomerEntity; if (!customerEntity) { return response.status(StatusCodes.BAD_REQUEST).json({ error: 'User exists in database: Customer was not created', @@ -480,14 +484,14 @@ export class AccountController { // 4. Check the token balance for Testnet account // 1. Get logTo UserId from request body - const { username } = request.body; + const { name, primaryEmail } = request.body; try { // 2. Check if the customer exists const customerEntity = response.locals.customer ? (response.locals.customer as CustomerEntity) : // 2.1 Create customer - ((await CustomerService.instance.create(username)) as CustomerEntity); + ((await CustomerService.instance.create(name || primaryEmail, primaryEmail)) as CustomerEntity); if (!customerEntity) { return response.status(StatusCodes.BAD_REQUEST).json({ diff --git a/src/database/entities/customer.entity.ts b/src/database/entities/customer.entity.ts index 4c6d705d2..987b9e341 100644 --- a/src/database/entities/customer.entity.ts +++ b/src/database/entities/customer.entity.ts @@ -16,9 +16,10 @@ export class CustomerEntity { @Column({ type: 'text', - nullable: true, + nullable: false, + unique: true, }) - email?: string; + email!: string; @Column({ type: 'text', @@ -54,7 +55,7 @@ export class CustomerEntity { this.updatedAt = new Date(); } - constructor(customerId: string, name: string, email?: string, description?: string, paymentProviderId?: string) { + constructor(customerId: string, name: string, email: string, description?: string, paymentProviderId?: string) { this.customerId = customerId; this.name = name; this.email = email; @@ -67,7 +68,7 @@ export class CustomerEntity { public isEqual(customer: CustomerEntity): boolean { return ( this.customerId === customer.customerId && - this.name === customer.name && + this.email === customer.email && this.createdAt.toISOString() === customer.createdAt.toISOString() && ((!this.updatedAt && !customer.updatedAt) || this.updatedAt.toISOString() === customer.updatedAt.toISOString()) diff --git a/src/database/migrations/AlterCustomerTableUniqueEmail.ts b/src/database/migrations/AlterCustomerTableUniqueEmail.ts new file mode 100644 index 000000000..47c764670 --- /dev/null +++ b/src/database/migrations/AlterCustomerTableUniqueEmail.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner, TableColumn, TableUnique } from 'typeorm'; + +export class AlterCustomerTableUpdateEmail1695740346006 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const tableName = 'customer'; + + // Make the email column NOT NULL + await queryRunner.changeColumn( + tableName, + 'email', + new TableColumn({ + name: 'email', + type: 'text', + isNullable: false, // Set the column as NOT NULL + }) + ); + + // Add a unique constraint to the email column + await queryRunner.createUniqueConstraint( + tableName, + new TableUnique({ + name: 'UQ_customer_email', + columnNames: ['email'], + }) + ); + } + + public async down(queryRunner: QueryRunner): Promise { + const tableName = 'customer'; + + // Remove the unique constraint + await queryRunner.dropUniqueConstraint(tableName, 'UQ_customer_email'); + + // Revert the email column back to nullable + await queryRunner.changeColumn( + tableName, + 'email', + new TableColumn({ + name: 'email', + type: 'text', + isNullable: true, // Revert to nullable + }) + ); + } +} diff --git a/src/database/migrations/MigrateData.ts b/src/database/migrations/MigrateData.ts index 3bf0f4034..89c42e7a3 100644 --- a/src/database/migrations/MigrateData.ts +++ b/src/database/migrations/MigrateData.ts @@ -99,7 +99,7 @@ export class MigrateData1695740345977 implements MigrationInterface { for (const oldCustomer of await queryRunner.query(`SELECT * FROM customers`)) { // 1. Create customer row: console.info(`Creating CustomerEntity with id ${oldCustomer.customerId}`); - const customerEntity = new CustomerEntity(uuidv4(), oldCustomer.address); + const customerEntity = new CustomerEntity(uuidv4(), oldCustomer.name, oldCustomer.email); customerEntity.createdAt = new Date(); console.info(`Creating customer with address ${oldCustomer.address}`); diff --git a/src/database/types/types.ts b/src/database/types/types.ts index ac357d54f..ed083a315 100644 --- a/src/database/types/types.ts +++ b/src/database/types/types.ts @@ -39,6 +39,7 @@ import { SubscriptionEntity } from '../entities/subscription.entity.js'; import { CreateSubscritpionTable1695740346003 } from '../migrations/CreateSubscriptionTable.js'; import { AlterAPIKeyTable1695740346004 } from '../migrations/AlterAPIKeyTable.js'; import { AlterCustomerTableAddEmail1695740346005 } from '../migrations/AlterCustomerTableAddEmail.js'; +import { AlterCustomerTableUpdateEmail1695740346006 } from '../migrations/AlterCustomerTableUniqueEmail.js'; dotenv.config(); const { EXTERNAL_DB_CONNECTION_URL, EXTERNAL_DB_CERT } = process.env; @@ -115,6 +116,8 @@ export class Postgres implements AbstractDatabase { AlterAPIKeyTable1695740346004, // Add email and description fields AlterCustomerTableAddEmail1695740346005, + // Add unique constraint to email field + AlterCustomerTableUpdateEmail1695740346006, ], entities: [ ...Entities, diff --git a/src/services/admin/stripe.ts b/src/services/admin/stripe.ts index 3e07c8540..bdf81cc56 100644 --- a/src/services/admin/stripe.ts +++ b/src/services/admin/stripe.ts @@ -4,19 +4,17 @@ import { SubscriptionService } from './subscription.js'; import type { CustomerEntity } from '../../database/entities/customer.entity.js'; import { EventTracker, eventTracker } from '../track/tracker.js'; import type { SubscriptionEntity } from '../../database/entities/subscription.entity.js'; -import { buildSubmitOperation } from '../track/helpers.js'; -import { OperationNameEnum } from '../../types/constants.js'; -import { SubscriptionSubmitter } from '../track/admin/subscription-submitter.js'; import type { NextFunction } from 'express'; +import { WebhookController } from '../../controllers/admin/webhook.js'; dotenv.config(); export class StripeService { - submitter: SubscriptionSubmitter; private isFullySynced = false; + private stripe: Stripe; constructor() { - this.submitter = new SubscriptionSubmitter(eventTracker.getEmitter()); + this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY); } async syncAll(next: NextFunction): Promise { @@ -28,9 +26,8 @@ export class StripeService { } async syncFull(): Promise { - const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); // Sync all subscriptions - for await (const subscription of stripe.subscriptions.list({ + for await (const subscription of this.stripe.subscriptions.list({ status: 'all', })) { const current = await SubscriptionService.instance.subscriptionRepository.findOne({ @@ -53,8 +50,7 @@ export class StripeService { // Sync all the subscriptions for current customer async syncCustomer(customer: CustomerEntity): Promise { - const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); - for await (const subscription of stripe.subscriptions.list({ + for await (const subscription of this.stripe.subscriptions.list({ customer: customer.paymentProviderId, status: 'all', })) { @@ -70,8 +66,6 @@ export class StripeService { } async syncOne(customer: CustomerEntity): Promise { - const stripe = new Stripe(process.env.STRIPE_SECRET_KEY); - const local = await SubscriptionService.instance.findCurrent(customer); if (!local) { await eventTracker.notify({ @@ -81,11 +75,11 @@ export class StripeService { ), severity: 'debug', }); - const activeSubs = await stripe.subscriptions.list({ + const activeSubs = await this.stripe.subscriptions.list({ customer: customer.paymentProviderId, status: 'active', }); - const trialSubs = await stripe.subscriptions.list({ + const trialSubs = await this.stripe.subscriptions.list({ customer: customer.paymentProviderId, status: 'trialing', }); @@ -106,7 +100,7 @@ export class StripeService { return; } const subscriptionId = local.subscriptionId; - const remote = await stripe.subscriptions.retrieve(subscriptionId); + const remote = await this.stripe.subscriptions.retrieve(subscriptionId); if (!remote) { await eventTracker.notify({ message: EventTracker.compileBasicNotification( @@ -128,9 +122,7 @@ export class StripeService { } async createSubscription(subscription: Stripe.Subscription, customer?: CustomerEntity): Promise { - await this.submitter.submitSubscriptionCreate( - buildSubmitOperation(subscription, OperationNameEnum.SUBSCRIPTION_CREATE, { customer: customer }) - ); + await WebhookController.instance.handleSubscriptionCreate(this.stripe, subscription, customer); } async updateSubscription(subscription: Stripe.Subscription, current: SubscriptionEntity): Promise { @@ -145,9 +137,7 @@ export class StripeService { }); return; } - await this.submitter.submitSubscriptionUpdate( - buildSubmitOperation(subscription, OperationNameEnum.SUBSCRIPTION_UPDATE) - ); + await WebhookController.instance.handleSubscriptionUpdate(this.stripe, subscription); } } diff --git a/src/services/api/customer.ts b/src/services/api/customer.ts index 4531ceab5..a300da6dc 100644 --- a/src/services/api/customer.ts +++ b/src/services/api/customer.ts @@ -22,14 +22,14 @@ export class CustomerService { this.customerRepository = Connection.instance.dbConnection.getRepository(CustomerEntity); } - public async create(name: string, email?: string, description?: string, paymentProviderId?: string) { + public async create(name: string, email: string, description?: string, paymentProviderId?: string) { // The sequence for creating a customer is supposed to be: // 1. Create a new customer entity in the database; // 2. Create new cosmos keypair // 3. Get the cosmos address from the keypair // 4. Create a new payment account entity in the database - if (await this.isExist({ name: name })) { + if (await this.isExist({ email: email })) { throw new Error(`Cannot create a new customer since the customer with same name ${name} already exists`); } const customerEntity = new CustomerEntity(uuidv4(), name, email, description, paymentProviderId); @@ -41,10 +41,7 @@ export class CustomerService { customerEntity ); await PaymentAccountService.instance.create(CheqdNetwork.Testnet, true, customerEntity, key); - return { - customerId: customerEntity.customerId, - name: customerEntity.name, - }; + return customerEntity; } public async update(customer: UpdateCustomerEntity) { diff --git a/src/services/track/admin/subscription-submitter.ts b/src/services/track/admin/subscription-submitter.ts deleted file mode 100644 index 6984b3b77..000000000 --- a/src/services/track/admin/subscription-submitter.ts +++ /dev/null @@ -1,322 +0,0 @@ -import Stripe from 'stripe'; -import type { CustomerEntity } from '../../../database/entities/customer.entity.js'; -import { OperationNameEnum } from '../../../types/constants.js'; -import type { INotifyMessage } from '../../../types/track.js'; -import { SubscriptionService } from '../../admin/subscription.js'; -import { CustomerService } from '../../api/customer.js'; -import type { ISubmitOperation, ISubmitSubscriptionData } from '../submitter.js'; -import { EventTracker } from '../tracker.js'; -import type { IObserver } from '../types.js'; -import type { FindOptionsWhere } from 'typeorm'; -import { LogToHelper } from '../../../middleware/auth/logto-helper.js'; -import { StatusCodes } from 'http-status-codes'; -import type { SupportedPlanTypes } from '../../../types/admin.js'; -import { UserService } from '../../api/user.js'; - -export class SubscriptionSubmitter implements IObserver { - private emitter: EventEmitter; - private readonly stripe: Stripe; - - constructor(emitter: EventEmitter) { - this.emitter = emitter; - this.stripe = new Stripe(process.env.STRIPE_SECRET_KEY); - } - - notify(notifyMessage: INotifyMessage): void { - this.emitter.emit('notify', notifyMessage); - } - - async update(operation: ISubmitOperation): Promise { - switch (operation.operation) { - case OperationNameEnum.SUBSCRIPTION_CREATE: - await this.submitSubscriptionCreate(operation); - break; - - case OperationNameEnum.SUBSCRIPTION_UPDATE: - await this.submitSubscriptionUpdate(operation); - break; - - case OperationNameEnum.SUBSCRIPTION_CANCEL: - await this.submitSubscriptionCancel(operation); - break; - } - } - - private async handleCustomerRoleAssignment( - operation: ISubmitOperation, - logToHelper: LogToHelper, - userLogtoId: string, - productName: string - ) { - const roleAssignmentResponse = await logToHelper.assignCustomerPlanRoles( - userLogtoId, - productName.toLowerCase() as SupportedPlanTypes - ); - if (roleAssignmentResponse.status !== 201) { - this.notify({ - message: EventTracker.compileBasicNotification( - `Failed to assign roles to user for planType ${productName}: ${roleAssignmentResponse.error}`, - operation.operation - ), - severity: 'error', - }); - return; - } - - this.notify({ - message: EventTracker.compileBasicNotification( - `${productName} plan assigned to user with logtoId ${userLogtoId}`, - operation.operation - ), - severity: 'info', - }); - } - - private async handleCustomerRoleRemoval(operation: ISubmitOperation, logto: LogToHelper, userLogtoId: string) { - const responses = await Promise.allSettled([ - logto.removeLogtoRoleFromUser(userLogtoId, process.env.LOGTO_TESTNET_ROLE_ID.trim()), - logto.removeLogtoRoleFromUser(userLogtoId, process.env.LOGTO_MAINNET_ROLE_ID.trim()), - ]); - - const allRolesRemoved = responses.every((r) => r.status === 'fulfilled' && r.value.status === StatusCodes.OK); - if (allRolesRemoved) { - this.notify({ - message: EventTracker.compileBasicNotification( - `Roles have been removed successfully for user with id: ${userLogtoId}`, - operation.operation - ), - severity: 'info', - }); - return; - } - - for (const resp of responses) { - if (resp.status === 'rejected' || resp.value.status !== StatusCodes.OK) { - const errMsg = resp.status === 'rejected' ? (resp.reason as Error).message : resp.value.error; - this.notify({ - message: EventTracker.compileBasicNotification( - `Role removal error: ${errMsg}`, - operation.operation - ), - severity: 'error', - }); - } - } - } - - private async syncLogtoRoles(operation: ISubmitOperation, customerId: string, productName: string) { - const logToHelper = new LogToHelper(); - const setupResp = await logToHelper.setup(); - if (setupResp.status !== StatusCodes.OK) { - this.notify({ - message: EventTracker.compileBasicNotification( - `Logto client initialisation failed: ${setupResp.error}`, - operation.operation - ), - severity: 'error', - }); - - return; - } - - const user = await UserService.instance.userRepository.findOne({ where: { customer: { customerId } } }); - - if (user) { - switch (operation.operation) { - case OperationNameEnum.SUBSCRIPTION_CREATE: - case OperationNameEnum.SUBSCRIPTION_UPDATE: - this.handleCustomerRoleAssignment(operation, logToHelper, user.logToId, productName); - return; - case OperationNameEnum.SUBSCRIPTION_CANCEL: - this.handleCustomerRoleRemoval(operation, logToHelper, user.logToId); - return; - } - } - - this.notify({ - message: EventTracker.compileBasicNotification( - `Role assignment failed: No user found with customerId: ${customerId}`, - operation.operation - ), - severity: 'error', - }); - } - - async submitSubscriptionCreate(operation: ISubmitOperation): Promise { - const data = operation.data as ISubmitSubscriptionData; - let customer: CustomerEntity | undefined = operation.options?.customer; - - try { - const [product, stripeCustomer] = await Promise.all([ - this.stripe.products.retrieve(data.productId), - this.stripe.customers.retrieve(data.paymentProviderId), - ]); - if (!customer) { - const whereClause: FindOptionsWhere[] = [{ paymentProviderId: data.paymentProviderId }]; - // we add an additional "OR" check in case that a customer was created locally with email and no paymentProviderId - if (!stripeCustomer.deleted && stripeCustomer.email) { - whereClause.push({ email: stripeCustomer.email }); - } - - const customers = await CustomerService.instance.customerRepository.find({ - where: whereClause, - }); - if (customers.length === 0) { - this.notify({ - message: EventTracker.compileBasicNotification( - `Customer not found for Cheqd Studio, creating new customer record with paymentProviderId: ${data.paymentProviderId}`, - operation.operation - ), - severity: 'info', - }); - - if (!stripeCustomer.deleted && stripeCustomer.email) { - const customerName = stripeCustomer.name ?? stripeCustomer.email; - const customer = await CustomerService.instance.create( - customerName, - stripeCustomer.email, - undefined, - data.paymentProviderId - ); - customers.push(customer as CustomerEntity); - } - } - - if (customers.length !== 1) { - this.notify({ - message: EventTracker.compileBasicNotification( - `Only one Stripe account should be associated with CaaS customer. Stripe accountId: ${data.paymentProviderId}.`, - operation.operation - ), - severity: 'error', - }); - } - customer = customers[0]; - } - - const subscription = await SubscriptionService.instance.create( - data.subscriptionId, - customer, - data.status, - data.currentPeriodStart, - data.currentPeriodEnd, - data.trialStart as Date, - data.trialEnd as Date - ); - if (!subscription) { - this.notify({ - message: EventTracker.compileBasicNotification( - `Failed to create a new subscription with id: ${data.subscriptionId}.`, - operation.operation - ), - severity: 'error', - }); - } - - await this.syncLogtoRoles(operation, customer.customerId, product.name); - - this.notify({ - message: EventTracker.compileBasicNotification( - `Subscription created with id: ${data.subscriptionId}.`, - operation.operation - ), - severity: 'info', - }); - } catch (error) { - this.notify({ - message: EventTracker.compileBasicNotification( - `Failed to create a new subscription with id: ${data.subscriptionId} because of error: ${(error as Error)?.message || error}`, - operation.operation - ), - severity: 'error', - }); - } - } - - async submitSubscriptionUpdate(operation: ISubmitOperation): Promise { - const data = operation.data as ISubmitSubscriptionData; - - try { - const subscription = await SubscriptionService.instance.update( - data.subscriptionId, - data.status, - data.currentPeriodStart, - data.currentPeriodEnd, - data.trialStart as Date, - data.trialEnd as Date - ); - if (!subscription) { - this.notify({ - message: EventTracker.compileBasicNotification( - `Failed to update subscription with id: ${data.subscriptionId}.`, - operation.operation - ), - severity: 'error', - }); - } - - const [customer, product] = await Promise.all([ - CustomerService.instance.findbyPaymentProviderId(data.paymentProviderId), - this.stripe.products.retrieve(data.productId), - ]); - - if (customer) { - await this.syncLogtoRoles(operation, customer.customerId, product.name); - } - - this.notify({ - message: EventTracker.compileBasicNotification( - `Subscription updated with id: ${data.subscriptionId}.`, - operation.operation - ), - severity: 'info', - }); - } catch (error) { - this.notify({ - message: EventTracker.compileBasicNotification( - `Failed to update subscription with id: ${data.subscriptionId} because of error: ${(error as Error)?.message || error}`, - operation.operation - ), - severity: 'error', - }); - } - } - - async submitSubscriptionCancel(operation: ISubmitOperation): Promise { - const data = operation.data as ISubmitSubscriptionData; - - try { - const subscription = await SubscriptionService.instance.update(data.subscriptionId, data.status); - if (!subscription) { - this.notify({ - message: EventTracker.compileBasicNotification( - `Failed to cancel subscription with id: ${data.subscriptionId}.`, - operation.operation - ), - severity: 'error', - }); - } - - const customer = await CustomerService.instance.findbyPaymentProviderId(data.paymentProviderId); - if (customer) { - this.syncLogtoRoles(operation, customer.customerId, ''); - } - - this.notify({ - message: EventTracker.compileBasicNotification( - `Subscription canceled with id: ${data.subscriptionId}.`, - operation.operation - ), - severity: 'info', - }); - } catch (error) { - this.notify({ - message: EventTracker.compileBasicNotification( - `Failed to cancel subscription with id: ${data.subscriptionId} because of error: ${(error as Error)?.message || error}`, - operation.operation - ), - severity: 'error', - }); - } - } -} diff --git a/src/services/track/helpers.ts b/src/services/track/helpers.ts index 0bc81b183..4a0702548 100644 --- a/src/services/track/helpers.ts +++ b/src/services/track/helpers.ts @@ -39,17 +39,21 @@ export function toCoin(amount: bigint, denom = MINIMAL_DENOM): Coin { export function buildSubmitOperation(subscription: Stripe.Subscription, name: string, options?: ISubmitOptions) { return { operation: name, - data: { - subscriptionId: subscription.id, - paymentProviderId: subscription.customer as string, - status: subscription.status, - currentPeriodStart: new Date(subscription.current_period_start * 1000), - currentPeriodEnd: new Date(subscription.current_period_end * 1000), - trialStart: subscription.trial_start ? new Date(subscription.trial_start * 1000) : undefined, - trialEnd: subscription.trial_end ? new Date(subscription.trial_end * 1000) : undefined, - productId: getStripeObjectKey(subscription.items.data[0].plan.product), - priceId: getStripeObjectKey(subscription.items.data[0].plan.id), - } satisfies ISubmitData, + data: buildSubscriptionData(subscription), options, } satisfies ISubmitOperation; } + +export function buildSubscriptionData(subscription: Stripe.Subscription) { + return { + subscriptionId: subscription.id, + paymentProviderId: subscription.customer as string, + status: subscription.status, + currentPeriodStart: new Date(subscription.current_period_start * 1000), + currentPeriodEnd: new Date(subscription.current_period_end * 1000), + trialStart: subscription.trial_start ? new Date(subscription.trial_start * 1000) : undefined, + trialEnd: subscription.trial_end ? new Date(subscription.trial_end * 1000) : undefined, + productId: getStripeObjectKey(subscription.items.data[0].plan.product), + priceId: getStripeObjectKey(subscription.items.data[0].plan.id), + } satisfies ISubmitData; +} diff --git a/src/services/track/tracker.ts b/src/services/track/tracker.ts index 864b5b872..e17c0c10a 100644 --- a/src/services/track/tracker.ts +++ b/src/services/track/tracker.ts @@ -11,7 +11,6 @@ import type { ITrackType } from './types.js'; import { SubmitSubject, TrackSubject } from './observer.js'; import type { ISubmitOperation } from './submitter.js'; import { PortalAccountCreateSubmitter } from './admin/account-submitter.js'; -import { SubscriptionSubmitter } from './admin/subscription-submitter.js'; export class EventTracker { readonly emitter: EventEmitter; @@ -52,7 +51,6 @@ export class EventTracker { setupDefaultSubmitters() { this.submitter.attach(new PortalAccountCreateSubmitter(this.getEmitter())); - this.submitter.attach(new SubscriptionSubmitter(this.getEmitter())); } getEmitter(): EventEmitter { diff --git a/src/static/swagger-api.json b/src/static/swagger-api.json index 4a049038b..83ebb20dc 100644 --- a/src/static/swagger-api.json +++ b/src/static/swagger-api.json @@ -2028,7 +2028,13 @@ "properties": { "user": { "type": "object", + "required": [ + "primaryEmail" + ], "properties": { + "name": { + "type": "string" + }, "primaryEmail": { "type": "string" } diff --git a/src/types/swagger-api-types.ts b/src/types/swagger-api-types.ts index 0cbc9bdbb..95640e746 100644 --- a/src/types/swagger-api-types.ts +++ b/src/types/swagger-api-types.ts @@ -1338,7 +1338,11 @@ * properties: * user: * type: object + * required: + * - primaryEmail * properties: + * name: + * type: string * primaryEmail: * type: string * InvalidRequest: