From 37f36df9e86c43524a97a5c380ca5439f3b7b62a Mon Sep 17 00:00:00 2001 From: Daniel Nigusse Date: Sat, 24 Aug 2024 20:51:03 +0300 Subject: [PATCH] Revert "feat: made db transaction atomic for major cases" --- app/db/data-source.ts | 7 +- .../migrations/1724278857374-initial_table.ts | 54 +++ .../migrations/1724285282196-initial_table.ts | 76 ++++ .../migrations/1724317920258-user_account.ts | 16 + ...refactor_customer_subscription_and_plan.ts | 66 +++ ...system_setting_table_and_update_payment.ts | 62 +++ ...7209527-update-customer-nextBillingDate.ts | 16 + app/db/migrations/1724457953023-intial.ts | 64 --- ...460259783-make_nextBillingDate_nullable.ts | 14 - app/db/seeders/settings/create.runner.ts | 5 +- app/db/seeders/settings/create.seeder.ts | 8 - app/package.json | 1 + app/src/app.module.ts | 2 - app/src/controllers/payment.controller.ts | 32 -- app/src/controllers/webhooks.controller.ts | 88 +--- app/src/dtos/payment.dto.ts | 8 +- app/src/entities/base.entity.ts | 2 - app/src/entities/customer.entity.ts | 2 +- .../exceptions/not-found-exception.filter.ts | 26 -- app/src/main.ts | 2 - app/src/processors/billing.processor.ts | 26 +- app/src/processors/payment.processor.ts | 42 +- app/src/services/billing.service.ts | 42 +- app/src/services/payment.service.ts | 422 +++++++++--------- app/src/services/subscription.service.ts | 88 ++-- .../controllers/webhooks.controller.spec.ts | 250 ++++------- .../processors/billing.processor.spec.ts | 36 +- .../processors/payment.processor.spec.ts | 150 +++---- app/tests/services/billing.service.spec.ts | 26 +- app/tests/services/payment.service.spec.ts | 261 ++++++----- .../services/subscription.service.spec.ts | 34 +- app/tsconfig.json | 4 +- 32 files changed, 898 insertions(+), 1034 deletions(-) create mode 100644 app/db/migrations/1724278857374-initial_table.ts create mode 100644 app/db/migrations/1724285282196-initial_table.ts create mode 100644 app/db/migrations/1724317920258-user_account.ts create mode 100644 app/db/migrations/1724323916157-refactor_customer_subscription_and_plan.ts create mode 100644 app/db/migrations/1724367208644-add_system_setting_table_and_update_payment.ts create mode 100644 app/db/migrations/1724417209527-update-customer-nextBillingDate.ts delete mode 100644 app/db/migrations/1724457953023-intial.ts delete mode 100644 app/db/migrations/1724460259783-make_nextBillingDate_nullable.ts delete mode 100644 app/src/controllers/payment.controller.ts delete mode 100644 app/src/exceptions/not-found-exception.filter.ts diff --git a/app/db/data-source.ts b/app/db/data-source.ts index 7537d4d..e550c67 100644 --- a/app/db/data-source.ts +++ b/app/db/data-source.ts @@ -1,9 +1,5 @@ import { DataSource, DataSourceOptions } from 'typeorm'; import { ConfigService } from '@nestjs/config'; -import * as dotenv from 'dotenv'; - -// Load .env file manually -dotenv.config(); const config = new ConfigService(); @@ -13,11 +9,10 @@ export const dataSrouceOptions: DataSourceOptions = { host: config.get('DB_HOST'), port: parseInt(config.get('DB_PORT') as string), username: config.get('DB_USER'), - password: config.get('DB_PASSWORD'), + password: config.get('DB_PASSWORD') as string, entities: ['dist/**/*.entity.js'], migrations: ['dist/db/migrations/*.js'], }; const dataSource = new DataSource(dataSrouceOptions); - export default dataSource; diff --git a/app/db/migrations/1724278857374-initial_table.ts b/app/db/migrations/1724278857374-initial_table.ts new file mode 100644 index 0000000..718dc1e --- /dev/null +++ b/app/db/migrations/1724278857374-initial_table.ts @@ -0,0 +1,54 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class InitialTable1724278857374 implements MigrationInterface { + name = 'InitialTable1724278857374' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "data_lookup" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "type" character varying(256) NOT NULL, "name" character varying(256) NOT NULL, "value" character varying(256) NOT NULL, "description" character varying, "category" character varying(256), "note" character varying, "index" integer NOT NULL DEFAULT '0', "is_default" boolean NOT NULL DEFAULT false, "is_active" boolean NOT NULL DEFAULT true, "remark" character varying, "createdDate" TIMESTAMP NOT NULL DEFAULT now(), "updatedDate" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_9f60ded44dcddb72401bd6e0d73" UNIQUE ("value"), CONSTRAINT "PK_e50dfedbaea85294d054845459e" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "customers" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdDate" TIMESTAMP NOT NULL DEFAULT now(), "updatedDate" TIMESTAMP NOT NULL DEFAULT now(), "firstName" character varying NOT NULL, "lastName" character varying NOT NULL, "email" character varying NOT NULL, "objectStateId" uuid, CONSTRAINT "UQ_8536b8b85c06969f84f0c098b03" UNIQUE ("email"), CONSTRAINT "PK_133ec679a801fab5e070f73d3ea" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "payment_methods" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdDate" TIMESTAMP NOT NULL DEFAULT now(), "updatedDate" TIMESTAMP NOT NULL DEFAULT now(), "name" character varying(100) NOT NULL, "accountHolderName" character varying(100), "accountNumber" character varying(50), "logo" character varying, "objectStateId" uuid, "typeId" uuid, "statusId" uuid, CONSTRAINT "UQ_a793d7354d7c3aaf76347ee5a66" UNIQUE ("name"), CONSTRAINT "PK_34f9b8c6dfb4ac3559f7e2820d1" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "payments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdDate" TIMESTAMP NOT NULL DEFAULT now(), "updatedDate" TIMESTAMP NOT NULL DEFAULT now(), "amount" numeric(10,2) NOT NULL, "currency" character varying(3) NOT NULL, "referenceNumber" character varying(100) NOT NULL, "payerName" character varying(100) NOT NULL, "paymentDate" date, "objectStateId" uuid, "statusId" uuid, "subscriptionId" uuid, "paymentMethodId" uuid, CONSTRAINT "PK_197ab7af18c93fbb0c9b28b4a59" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "subscriptions" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdDate" TIMESTAMP NOT NULL DEFAULT now(), "updatedDate" TIMESTAMP NOT NULL DEFAULT now(), "startDate" TIMESTAMP NOT NULL, "endDate" TIMESTAMP, "objectStateId" uuid, "customerId" uuid, "planId" uuid, "statusId" uuid, CONSTRAINT "PK_a87248d73155605cf782be9ee5e" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "subscription_plans" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdDate" TIMESTAMP NOT NULL DEFAULT now(), "updatedDate" TIMESTAMP NOT NULL DEFAULT now(), "name" character varying NOT NULL, "price" numeric NOT NULL, "billingCycleDays" integer NOT NULL, "objectStateId" uuid, "statusId" uuid, CONSTRAINT "PK_9ab8fe6918451ab3d0a4fb6bb0c" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE TABLE "invoices" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "customerId" character varying NOT NULL, "amount" numeric NOT NULL, "status" character varying NOT NULL DEFAULT 'pending', "paymentDueDate" date NOT NULL, "paymentDate" TIMESTAMP, CONSTRAINT "PK_668cef7c22a427fd822cc1be3ce" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "customers" ADD CONSTRAINT "FK_128b6a4d0e6f02a7076a604648c" FOREIGN KEY ("objectStateId") REFERENCES "data_lookup"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "payment_methods" ADD CONSTRAINT "FK_9b5a223a0b92d4804864af68e52" FOREIGN KEY ("objectStateId") REFERENCES "data_lookup"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "payment_methods" ADD CONSTRAINT "FK_137bf799227505edd74b045bcce" FOREIGN KEY ("typeId") REFERENCES "data_lookup"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "payment_methods" ADD CONSTRAINT "FK_f0a221fbe9c5b4004a1479f7bb9" FOREIGN KEY ("statusId") REFERENCES "data_lookup"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "payments" ADD CONSTRAINT "FK_4394f8cd4011218d070d80093d1" FOREIGN KEY ("objectStateId") REFERENCES "data_lookup"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "payments" ADD CONSTRAINT "FK_fe15297113c30bf8a39505ac568" FOREIGN KEY ("statusId") REFERENCES "data_lookup"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "payments" ADD CONSTRAINT "FK_2017d0cbfdbfec6b1b388e6aa08" FOREIGN KEY ("subscriptionId") REFERENCES "subscriptions"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "payments" ADD CONSTRAINT "FK_cbe18cae039006a9c217d5a66a6" FOREIGN KEY ("paymentMethodId") REFERENCES "payment_methods"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "subscriptions" ADD CONSTRAINT "FK_42329dc93149312811cb8dc7c07" FOREIGN KEY ("objectStateId") REFERENCES "data_lookup"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "subscriptions" ADD CONSTRAINT "FK_e0fbe75e9db162a00ecaf7ab56a" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "subscriptions" ADD CONSTRAINT "FK_7536cba909dd7584a4640cad7d5" FOREIGN KEY ("planId") REFERENCES "subscription_plans"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "subscriptions" ADD CONSTRAINT "FK_65f3292e30fccbb0dfe4ae6dcb3" FOREIGN KEY ("statusId") REFERENCES "data_lookup"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD CONSTRAINT "FK_b21861cdd922eb98a449c752cdd" FOREIGN KEY ("objectStateId") REFERENCES "data_lookup"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD CONSTRAINT "FK_437b83f8551833438d63f78086f" FOREIGN KEY ("statusId") REFERENCES "data_lookup"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP CONSTRAINT "FK_437b83f8551833438d63f78086f"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP CONSTRAINT "FK_b21861cdd922eb98a449c752cdd"`); + await queryRunner.query(`ALTER TABLE "subscriptions" DROP CONSTRAINT "FK_65f3292e30fccbb0dfe4ae6dcb3"`); + await queryRunner.query(`ALTER TABLE "subscriptions" DROP CONSTRAINT "FK_7536cba909dd7584a4640cad7d5"`); + await queryRunner.query(`ALTER TABLE "subscriptions" DROP CONSTRAINT "FK_e0fbe75e9db162a00ecaf7ab56a"`); + await queryRunner.query(`ALTER TABLE "subscriptions" DROP CONSTRAINT "FK_42329dc93149312811cb8dc7c07"`); + await queryRunner.query(`ALTER TABLE "payments" DROP CONSTRAINT "FK_cbe18cae039006a9c217d5a66a6"`); + await queryRunner.query(`ALTER TABLE "payments" DROP CONSTRAINT "FK_2017d0cbfdbfec6b1b388e6aa08"`); + await queryRunner.query(`ALTER TABLE "payments" DROP CONSTRAINT "FK_fe15297113c30bf8a39505ac568"`); + await queryRunner.query(`ALTER TABLE "payments" DROP CONSTRAINT "FK_4394f8cd4011218d070d80093d1"`); + await queryRunner.query(`ALTER TABLE "payment_methods" DROP CONSTRAINT "FK_f0a221fbe9c5b4004a1479f7bb9"`); + await queryRunner.query(`ALTER TABLE "payment_methods" DROP CONSTRAINT "FK_137bf799227505edd74b045bcce"`); + await queryRunner.query(`ALTER TABLE "payment_methods" DROP CONSTRAINT "FK_9b5a223a0b92d4804864af68e52"`); + await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "FK_128b6a4d0e6f02a7076a604648c"`); + await queryRunner.query(`DROP TABLE "invoices"`); + await queryRunner.query(`DROP TABLE "subscription_plans"`); + await queryRunner.query(`DROP TABLE "subscriptions"`); + await queryRunner.query(`DROP TABLE "payments"`); + await queryRunner.query(`DROP TABLE "payment_methods"`); + await queryRunner.query(`DROP TABLE "customers"`); + await queryRunner.query(`DROP TABLE "data_lookup"`); + } + +} diff --git a/app/db/migrations/1724285282196-initial_table.ts b/app/db/migrations/1724285282196-initial_table.ts new file mode 100644 index 0000000..0c2131f --- /dev/null +++ b/app/db/migrations/1724285282196-initial_table.ts @@ -0,0 +1,76 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class InitialTable1724285282196 implements MigrationInterface { + name = 'InitialTable1724285282196' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "firstName"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "lastName"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD "deletedDate" TIMESTAMP`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD "description" character varying`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD "trialPeriodDays" integer`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD "maxUsers" integer`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD "maxStorage" numeric`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD "overageCharge" numeric`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD "autoRenewal" boolean NOT NULL DEFAULT true`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD "setupFee" numeric`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD "currency" character varying NOT NULL DEFAULT 'USD'`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD "discount" numeric`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD "cancellationPolicy" character varying`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD "gracePeriodDays" integer`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD "upgradeToPlanId" character varying`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD "downgradeToPlanId" character varying`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD "trialConversionPlanId" character varying`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD "prorate" boolean NOT NULL DEFAULT true`); + await queryRunner.query(`ALTER TABLE "customers" ADD "deletedDate" TIMESTAMP`); + await queryRunner.query(`ALTER TABLE "customers" ADD "name" character varying NOT NULL`); + await queryRunner.query(`ALTER TABLE "customers" ADD "phoneNumber" character varying`); + await queryRunner.query(`ALTER TABLE "customers" ADD "billingAddress" character varying`); + await queryRunner.query(`ALTER TABLE "customers" ADD "country" character varying`); + await queryRunner.query(`ALTER TABLE "customers" ADD "city" character varying`); + await queryRunner.query(`ALTER TABLE "customers" ADD "postalCode" character varying`); + await queryRunner.query(`ALTER TABLE "customers" ADD "subscriptionPlanId" uuid`); + await queryRunner.query(`ALTER TABLE "customers" ADD "subscriptionStatusId" uuid`); + await queryRunner.query(`ALTER TABLE "payment_methods" ADD "deletedDate" TIMESTAMP`); + await queryRunner.query(`ALTER TABLE "payments" ADD "deletedDate" TIMESTAMP`); + await queryRunner.query(`ALTER TABLE "subscriptions" ADD "deletedDate" TIMESTAMP`); + await queryRunner.query(`ALTER TABLE "customers" ADD CONSTRAINT "FK_88dd6cde3b31a1088abb2e9ad44" FOREIGN KEY ("subscriptionPlanId") REFERENCES "subscription_plans"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "customers" ADD CONSTRAINT "FK_8ed83c12a858234d7cfe192f79a" FOREIGN KEY ("subscriptionStatusId") REFERENCES "data_lookup"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "FK_8ed83c12a858234d7cfe192f79a"`); + await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "FK_88dd6cde3b31a1088abb2e9ad44"`); + await queryRunner.query(`ALTER TABLE "subscriptions" DROP COLUMN "deletedDate"`); + await queryRunner.query(`ALTER TABLE "payments" DROP COLUMN "deletedDate"`); + await queryRunner.query(`ALTER TABLE "payment_methods" DROP COLUMN "deletedDate"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "subscriptionStatusId"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "subscriptionPlanId"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "postalCode"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "city"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "country"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "billingAddress"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "phoneNumber"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "name"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "deletedDate"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP COLUMN "prorate"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP COLUMN "trialConversionPlanId"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP COLUMN "downgradeToPlanId"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP COLUMN "upgradeToPlanId"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP COLUMN "gracePeriodDays"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP COLUMN "cancellationPolicy"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP COLUMN "discount"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP COLUMN "currency"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP COLUMN "setupFee"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP COLUMN "autoRenewal"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP COLUMN "overageCharge"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP COLUMN "maxStorage"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP COLUMN "maxUsers"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP COLUMN "trialPeriodDays"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP COLUMN "description"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP COLUMN "deletedDate"`); + await queryRunner.query(`ALTER TABLE "customers" ADD "lastName" character varying NOT NULL`); + await queryRunner.query(`ALTER TABLE "customers" ADD "firstName" character varying NOT NULL`); + } + +} diff --git a/app/db/migrations/1724317920258-user_account.ts b/app/db/migrations/1724317920258-user_account.ts new file mode 100644 index 0000000..d46c30d --- /dev/null +++ b/app/db/migrations/1724317920258-user_account.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UserAccount1724317920258 implements MigrationInterface { + name = 'UserAccount1724317920258' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdDate" TIMESTAMP NOT NULL DEFAULT now(), "updatedDate" TIMESTAMP NOT NULL DEFAULT now(), "deletedDate" TIMESTAMP, "name" character varying NOT NULL, "email" character varying NOT NULL, "password" character varying NOT NULL, "phoneNumber" character varying, "objectStateId" uuid, CONSTRAINT "UQ_97672ac88f789774dd47f7c8be3" UNIQUE ("email"), CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`); + await queryRunner.query(`ALTER TABLE "users" ADD CONSTRAINT "FK_fb02bf159ff549154df233aa6e9" FOREIGN KEY ("objectStateId") REFERENCES "data_lookup"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "users" DROP CONSTRAINT "FK_fb02bf159ff549154df233aa6e9"`); + await queryRunner.query(`DROP TABLE "users"`); + } + +} diff --git a/app/db/migrations/1724323916157-refactor_customer_subscription_and_plan.ts b/app/db/migrations/1724323916157-refactor_customer_subscription_and_plan.ts new file mode 100644 index 0000000..9a4e918 --- /dev/null +++ b/app/db/migrations/1724323916157-refactor_customer_subscription_and_plan.ts @@ -0,0 +1,66 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class RefactorCustomerSubscriptionAndPlan1724323916157 implements MigrationInterface { + name = 'RefactorCustomerSubscriptionAndPlan1724323916157' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "payments" DROP CONSTRAINT "FK_2017d0cbfdbfec6b1b388e6aa08"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP COLUMN "trialPeriodDays"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP COLUMN "maxUsers"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP COLUMN "maxStorage"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP COLUMN "overageCharge"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP COLUMN "autoRenewal"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP COLUMN "setupFee"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP COLUMN "currency"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP COLUMN "discount"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP COLUMN "cancellationPolicy"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP COLUMN "gracePeriodDays"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP COLUMN "upgradeToPlanId"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP COLUMN "downgradeToPlanId"`); + await queryRunner.query(`ALTER TABLE "subscription_plans" DROP COLUMN "trialConversionPlanId"`); + await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "UQ_8536b8b85c06969f84f0c098b03"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "email"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "name"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "phoneNumber"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "billingAddress"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "country"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "city"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "postalCode"`); + await queryRunner.query(`ALTER TABLE "customers" ADD "startDate" TIMESTAMP NOT NULL`); + await queryRunner.query(`ALTER TABLE "customers" ADD "endDate" TIMESTAMP`); + await queryRunner.query(`ALTER TABLE "customers" ADD "userId" uuid`); + await queryRunner.query(`ALTER TABLE "payments" ADD CONSTRAINT "FK_2017d0cbfdbfec6b1b388e6aa08" FOREIGN KEY ("subscriptionId") REFERENCES "subscription_plans"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "customers" ADD CONSTRAINT "FK_b8512aa9cef03d90ed5744c94d7" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "FK_b8512aa9cef03d90ed5744c94d7"`); + await queryRunner.query(`ALTER TABLE "payments" DROP CONSTRAINT "FK_2017d0cbfdbfec6b1b388e6aa08"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "userId"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "endDate"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "startDate"`); + await queryRunner.query(`ALTER TABLE "customers" ADD "postalCode" character varying`); + await queryRunner.query(`ALTER TABLE "customers" ADD "city" character varying`); + await queryRunner.query(`ALTER TABLE "customers" ADD "country" character varying`); + await queryRunner.query(`ALTER TABLE "customers" ADD "billingAddress" character varying`); + await queryRunner.query(`ALTER TABLE "customers" ADD "phoneNumber" character varying`); + await queryRunner.query(`ALTER TABLE "customers" ADD "name" character varying NOT NULL`); + await queryRunner.query(`ALTER TABLE "customers" ADD "email" character varying NOT NULL`); + await queryRunner.query(`ALTER TABLE "customers" ADD CONSTRAINT "UQ_8536b8b85c06969f84f0c098b03" UNIQUE ("email")`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD "trialConversionPlanId" character varying`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD "downgradeToPlanId" character varying`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD "upgradeToPlanId" character varying`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD "gracePeriodDays" integer`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD "cancellationPolicy" character varying`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD "discount" numeric`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD "currency" character varying NOT NULL DEFAULT 'USD'`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD "setupFee" numeric`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD "autoRenewal" boolean NOT NULL DEFAULT true`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD "overageCharge" numeric`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD "maxStorage" numeric`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD "maxUsers" integer`); + await queryRunner.query(`ALTER TABLE "subscription_plans" ADD "trialPeriodDays" integer`); + await queryRunner.query(`ALTER TABLE "payments" ADD CONSTRAINT "FK_2017d0cbfdbfec6b1b388e6aa08" FOREIGN KEY ("subscriptionId") REFERENCES "subscriptions"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + +} diff --git a/app/db/migrations/1724367208644-add_system_setting_table_and_update_payment.ts b/app/db/migrations/1724367208644-add_system_setting_table_and_update_payment.ts new file mode 100644 index 0000000..32994b7 --- /dev/null +++ b/app/db/migrations/1724367208644-add_system_setting_table_and_update_payment.ts @@ -0,0 +1,62 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class AddSystemSettingTableAndUpdatePayment1724367208644 implements MigrationInterface { + name = 'AddSystemSettingTableAndUpdatePayment1724367208644' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "payment_methods" DROP CONSTRAINT "FK_f0a221fbe9c5b4004a1479f7bb9"`); + await queryRunner.query(`ALTER TABLE "payments" DROP CONSTRAINT "FK_2017d0cbfdbfec6b1b388e6aa08"`); + await queryRunner.query(`CREATE TABLE "system_setting" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdDate" TIMESTAMP NOT NULL DEFAULT now(), "updatedDate" TIMESTAMP NOT NULL DEFAULT now(), "deletedDate" TIMESTAMP, "name" character varying(255) NOT NULL, "code" character varying(100) NOT NULL, "defaultValue" text NOT NULL, "currentValue" text NOT NULL, "objectStateId" uuid, CONSTRAINT "PK_88dbc9b10c8558420acf7ea642f" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_34a784a7ee0ef3450ed68c08a2" ON "system_setting" ("code") `); + await queryRunner.query(`ALTER TABLE "payment_methods" DROP COLUMN "statusId"`); + await queryRunner.query(`ALTER TABLE "invoices" DROP COLUMN "status"`); + await queryRunner.query(`ALTER TABLE "payments" DROP COLUMN "currency"`); + await queryRunner.query(`ALTER TABLE "payments" DROP COLUMN "subscriptionId"`); + await queryRunner.query(`ALTER TABLE "payment_methods" ADD "code" character varying(50) NOT NULL`); + await queryRunner.query(`ALTER TABLE "payment_methods" ADD CONSTRAINT "UQ_f8aad3eab194dfdae604ca11125" UNIQUE ("code")`); + await queryRunner.query(`ALTER TABLE "customers" ADD "retryCount" integer NOT NULL DEFAULT '0'`); + await queryRunner.query(`ALTER TABLE "customers" ADD "nextRetry" TIMESTAMP`); + await queryRunner.query(`ALTER TABLE "invoices" ADD "createdDate" TIMESTAMP NOT NULL DEFAULT now()`); + await queryRunner.query(`ALTER TABLE "invoices" ADD "updatedDate" TIMESTAMP NOT NULL DEFAULT now()`); + await queryRunner.query(`ALTER TABLE "invoices" ADD "deletedDate" TIMESTAMP`); + await queryRunner.query(`ALTER TABLE "invoices" ADD "objectStateId" uuid`); + await queryRunner.query(`ALTER TABLE "invoices" ADD "statusId" uuid`); + await queryRunner.query(`ALTER TABLE "invoices" ADD "subscriptionId" uuid`); + await queryRunner.query(`ALTER TABLE "payments" ADD "invoiceId" uuid`); + await queryRunner.query(`ALTER TABLE "payment_methods" DROP CONSTRAINT "UQ_a793d7354d7c3aaf76347ee5a66"`); + await queryRunner.query(`ALTER TABLE "system_setting" ADD CONSTRAINT "FK_cfcbfe457665632818d9bb5f164" FOREIGN KEY ("objectStateId") REFERENCES "data_lookup"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "invoices" ADD CONSTRAINT "FK_0c4ec5c08dae801516118e0e897" FOREIGN KEY ("objectStateId") REFERENCES "data_lookup"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "invoices" ADD CONSTRAINT "FK_a595020add15845ff4cb1c743c8" FOREIGN KEY ("statusId") REFERENCES "data_lookup"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "invoices" ADD CONSTRAINT "FK_2c09534a63cf2e612ab2ca3a252" FOREIGN KEY ("subscriptionId") REFERENCES "customers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "payments" ADD CONSTRAINT "FK_43d19956aeab008b49e0804c145" FOREIGN KEY ("invoiceId") REFERENCES "invoices"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "payments" DROP CONSTRAINT "FK_43d19956aeab008b49e0804c145"`); + await queryRunner.query(`ALTER TABLE "invoices" DROP CONSTRAINT "FK_2c09534a63cf2e612ab2ca3a252"`); + await queryRunner.query(`ALTER TABLE "invoices" DROP CONSTRAINT "FK_a595020add15845ff4cb1c743c8"`); + await queryRunner.query(`ALTER TABLE "invoices" DROP CONSTRAINT "FK_0c4ec5c08dae801516118e0e897"`); + await queryRunner.query(`ALTER TABLE "system_setting" DROP CONSTRAINT "FK_cfcbfe457665632818d9bb5f164"`); + await queryRunner.query(`ALTER TABLE "payment_methods" ADD CONSTRAINT "UQ_a793d7354d7c3aaf76347ee5a66" UNIQUE ("name")`); + await queryRunner.query(`ALTER TABLE "payments" DROP COLUMN "invoiceId"`); + await queryRunner.query(`ALTER TABLE "invoices" DROP COLUMN "subscriptionId"`); + await queryRunner.query(`ALTER TABLE "invoices" DROP COLUMN "statusId"`); + await queryRunner.query(`ALTER TABLE "invoices" DROP COLUMN "objectStateId"`); + await queryRunner.query(`ALTER TABLE "invoices" DROP COLUMN "deletedDate"`); + await queryRunner.query(`ALTER TABLE "invoices" DROP COLUMN "updatedDate"`); + await queryRunner.query(`ALTER TABLE "invoices" DROP COLUMN "createdDate"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "nextRetry"`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "retryCount"`); + await queryRunner.query(`ALTER TABLE "payment_methods" DROP CONSTRAINT "UQ_f8aad3eab194dfdae604ca11125"`); + await queryRunner.query(`ALTER TABLE "payment_methods" DROP COLUMN "code"`); + await queryRunner.query(`ALTER TABLE "payments" ADD "subscriptionId" uuid`); + await queryRunner.query(`ALTER TABLE "payments" ADD "currency" character varying(3) NOT NULL`); + await queryRunner.query(`ALTER TABLE "invoices" ADD "status" character varying NOT NULL DEFAULT 'pending'`); + await queryRunner.query(`ALTER TABLE "payment_methods" ADD "statusId" uuid`); + await queryRunner.query(`DROP INDEX "public"."IDX_34a784a7ee0ef3450ed68c08a2"`); + await queryRunner.query(`DROP TABLE "system_setting"`); + await queryRunner.query(`ALTER TABLE "payments" ADD CONSTRAINT "FK_2017d0cbfdbfec6b1b388e6aa08" FOREIGN KEY ("subscriptionId") REFERENCES "subscription_plans"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "payment_methods" ADD CONSTRAINT "FK_f0a221fbe9c5b4004a1479f7bb9" FOREIGN KEY ("statusId") REFERENCES "data_lookup"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); + } + +} diff --git a/app/db/migrations/1724417209527-update-customer-nextBillingDate.ts b/app/db/migrations/1724417209527-update-customer-nextBillingDate.ts new file mode 100644 index 0000000..1888b5f --- /dev/null +++ b/app/db/migrations/1724417209527-update-customer-nextBillingDate.ts @@ -0,0 +1,16 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class UpdateCustomerNextBillingDate1724417209527 implements MigrationInterface { + name = 'UpdateCustomerNextBillingDate1724417209527' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "customers" ADD "nextBillingDate" TIMESTAMP NOT NULL`); + await queryRunner.query(`ALTER TABLE "invoices" ALTER COLUMN "amount" TYPE numeric(10,2)`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "invoices" ALTER COLUMN "amount" TYPE numeric`); + await queryRunner.query(`ALTER TABLE "customers" DROP COLUMN "nextBillingDate"`); + } + +} diff --git a/app/db/migrations/1724457953023-intial.ts b/app/db/migrations/1724457953023-intial.ts deleted file mode 100644 index d2538a5..0000000 --- a/app/db/migrations/1724457953023-intial.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; - -export class Intial1724457953023 implements MigrationInterface { - name = 'Intial1724457953023' - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`CREATE TABLE "data_lookup" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "type" character varying(256) NOT NULL, "name" character varying(256) NOT NULL, "value" character varying(256) NOT NULL, "description" character varying, "category" character varying(256), "note" character varying, "index" integer NOT NULL DEFAULT '0', "is_default" boolean NOT NULL DEFAULT false, "is_active" boolean NOT NULL DEFAULT true, "remark" character varying, "createdDate" TIMESTAMP NOT NULL DEFAULT now(), "updatedDate" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_9f60ded44dcddb72401bd6e0d73" UNIQUE ("value"), CONSTRAINT "PK_e50dfedbaea85294d054845459e" PRIMARY KEY ("id"))`); - await queryRunner.query(`CREATE TABLE "users" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdDate" TIMESTAMP NOT NULL DEFAULT now(), "updatedDate" TIMESTAMP NOT NULL DEFAULT now(), "deletedDate" TIMESTAMP, "name" character varying NOT NULL, "email" character varying NOT NULL, "password" character varying NOT NULL, "phoneNumber" character varying, "objectStateId" uuid, CONSTRAINT "UQ_97672ac88f789774dd47f7c8be3" UNIQUE ("email"), CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`); - await queryRunner.query(`CREATE TABLE "system_setting" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdDate" TIMESTAMP NOT NULL DEFAULT now(), "updatedDate" TIMESTAMP NOT NULL DEFAULT now(), "deletedDate" TIMESTAMP, "name" character varying(255) NOT NULL, "code" character varying(100) NOT NULL, "defaultValue" text NOT NULL, "currentValue" text NOT NULL, "objectStateId" uuid, CONSTRAINT "PK_88dbc9b10c8558420acf7ea642f" PRIMARY KEY ("id"))`); - await queryRunner.query(`CREATE UNIQUE INDEX "IDX_34a784a7ee0ef3450ed68c08a2" ON "system_setting" ("code") `); - await queryRunner.query(`CREATE TABLE "payment_methods" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdDate" TIMESTAMP NOT NULL DEFAULT now(), "updatedDate" TIMESTAMP NOT NULL DEFAULT now(), "deletedDate" TIMESTAMP, "code" character varying(50) NOT NULL, "name" character varying(100) NOT NULL, "accountHolderName" character varying(100), "accountNumber" character varying(50), "logo" character varying, "objectStateId" uuid, "typeId" uuid, CONSTRAINT "UQ_f8aad3eab194dfdae604ca11125" UNIQUE ("code"), CONSTRAINT "PK_34f9b8c6dfb4ac3559f7e2820d1" PRIMARY KEY ("id"))`); - await queryRunner.query(`CREATE TABLE "subscription_plans" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdDate" TIMESTAMP NOT NULL DEFAULT now(), "updatedDate" TIMESTAMP NOT NULL DEFAULT now(), "deletedDate" TIMESTAMP, "name" character varying NOT NULL, "description" character varying, "price" numeric NOT NULL, "billingCycleDays" integer NOT NULL, "prorate" boolean NOT NULL DEFAULT true, "objectStateId" uuid, "statusId" uuid, CONSTRAINT "PK_9ab8fe6918451ab3d0a4fb6bb0c" PRIMARY KEY ("id"))`); - await queryRunner.query(`CREATE TABLE "customers" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdDate" TIMESTAMP NOT NULL DEFAULT now(), "updatedDate" TIMESTAMP NOT NULL DEFAULT now(), "deletedDate" TIMESTAMP, "startDate" TIMESTAMP NOT NULL, "endDate" TIMESTAMP, "retryCount" integer NOT NULL DEFAULT '0', "nextRetry" TIMESTAMP, "nextBillingDate" TIMESTAMP NOT NULL, "objectStateId" uuid, "userId" uuid, "subscriptionPlanId" uuid, "subscriptionStatusId" uuid, CONSTRAINT "PK_133ec679a801fab5e070f73d3ea" PRIMARY KEY ("id"))`); - await queryRunner.query(`CREATE TABLE "invoices" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdDate" TIMESTAMP NOT NULL DEFAULT now(), "updatedDate" TIMESTAMP NOT NULL DEFAULT now(), "deletedDate" TIMESTAMP, "code" character varying(50) NOT NULL, "customerId" character varying NOT NULL, "amount" numeric(10,2) NOT NULL, "paymentDueDate" date NOT NULL, "paymentDate" TIMESTAMP, "objectStateId" uuid, "statusId" uuid, "subscriptionId" uuid, CONSTRAINT "UQ_e38e380c25aacf8cd59d6ae21fe" UNIQUE ("code"), CONSTRAINT "PK_668cef7c22a427fd822cc1be3ce" PRIMARY KEY ("id"))`); - await queryRunner.query(`CREATE TABLE "payments" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "createdDate" TIMESTAMP NOT NULL DEFAULT now(), "updatedDate" TIMESTAMP NOT NULL DEFAULT now(), "deletedDate" TIMESTAMP, "amount" numeric(10,2) NOT NULL, "referenceNumber" character varying(100) NOT NULL, "payerName" character varying(100) NOT NULL, "paymentDate" date, "objectStateId" uuid, "statusId" uuid, "invoiceId" uuid, "paymentMethodId" uuid, CONSTRAINT "PK_197ab7af18c93fbb0c9b28b4a59" PRIMARY KEY ("id"))`); - await queryRunner.query(`ALTER TABLE "users" ADD CONSTRAINT "FK_fb02bf159ff549154df233aa6e9" FOREIGN KEY ("objectStateId") REFERENCES "data_lookup"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE "system_setting" ADD CONSTRAINT "FK_cfcbfe457665632818d9bb5f164" FOREIGN KEY ("objectStateId") REFERENCES "data_lookup"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE "payment_methods" ADD CONSTRAINT "FK_9b5a223a0b92d4804864af68e52" FOREIGN KEY ("objectStateId") REFERENCES "data_lookup"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE "payment_methods" ADD CONSTRAINT "FK_137bf799227505edd74b045bcce" FOREIGN KEY ("typeId") REFERENCES "data_lookup"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE "subscription_plans" ADD CONSTRAINT "FK_b21861cdd922eb98a449c752cdd" FOREIGN KEY ("objectStateId") REFERENCES "data_lookup"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE "subscription_plans" ADD CONSTRAINT "FK_437b83f8551833438d63f78086f" FOREIGN KEY ("statusId") REFERENCES "data_lookup"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE "customers" ADD CONSTRAINT "FK_128b6a4d0e6f02a7076a604648c" FOREIGN KEY ("objectStateId") REFERENCES "data_lookup"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE "customers" ADD CONSTRAINT "FK_b8512aa9cef03d90ed5744c94d7" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE "customers" ADD CONSTRAINT "FK_88dd6cde3b31a1088abb2e9ad44" FOREIGN KEY ("subscriptionPlanId") REFERENCES "subscription_plans"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE "customers" ADD CONSTRAINT "FK_8ed83c12a858234d7cfe192f79a" FOREIGN KEY ("subscriptionStatusId") REFERENCES "data_lookup"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE "invoices" ADD CONSTRAINT "FK_0c4ec5c08dae801516118e0e897" FOREIGN KEY ("objectStateId") REFERENCES "data_lookup"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE "invoices" ADD CONSTRAINT "FK_a595020add15845ff4cb1c743c8" FOREIGN KEY ("statusId") REFERENCES "data_lookup"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE "invoices" ADD CONSTRAINT "FK_2c09534a63cf2e612ab2ca3a252" FOREIGN KEY ("subscriptionId") REFERENCES "customers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE "payments" ADD CONSTRAINT "FK_4394f8cd4011218d070d80093d1" FOREIGN KEY ("objectStateId") REFERENCES "data_lookup"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE "payments" ADD CONSTRAINT "FK_fe15297113c30bf8a39505ac568" FOREIGN KEY ("statusId") REFERENCES "data_lookup"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE "payments" ADD CONSTRAINT "FK_43d19956aeab008b49e0804c145" FOREIGN KEY ("invoiceId") REFERENCES "invoices"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE "payments" ADD CONSTRAINT "FK_cbe18cae039006a9c217d5a66a6" FOREIGN KEY ("paymentMethodId") REFERENCES "payment_methods"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "payments" DROP CONSTRAINT "FK_cbe18cae039006a9c217d5a66a6"`); - await queryRunner.query(`ALTER TABLE "payments" DROP CONSTRAINT "FK_43d19956aeab008b49e0804c145"`); - await queryRunner.query(`ALTER TABLE "payments" DROP CONSTRAINT "FK_fe15297113c30bf8a39505ac568"`); - await queryRunner.query(`ALTER TABLE "payments" DROP CONSTRAINT "FK_4394f8cd4011218d070d80093d1"`); - await queryRunner.query(`ALTER TABLE "invoices" DROP CONSTRAINT "FK_2c09534a63cf2e612ab2ca3a252"`); - await queryRunner.query(`ALTER TABLE "invoices" DROP CONSTRAINT "FK_a595020add15845ff4cb1c743c8"`); - await queryRunner.query(`ALTER TABLE "invoices" DROP CONSTRAINT "FK_0c4ec5c08dae801516118e0e897"`); - await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "FK_8ed83c12a858234d7cfe192f79a"`); - await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "FK_88dd6cde3b31a1088abb2e9ad44"`); - await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "FK_b8512aa9cef03d90ed5744c94d7"`); - await queryRunner.query(`ALTER TABLE "customers" DROP CONSTRAINT "FK_128b6a4d0e6f02a7076a604648c"`); - await queryRunner.query(`ALTER TABLE "subscription_plans" DROP CONSTRAINT "FK_437b83f8551833438d63f78086f"`); - await queryRunner.query(`ALTER TABLE "subscription_plans" DROP CONSTRAINT "FK_b21861cdd922eb98a449c752cdd"`); - await queryRunner.query(`ALTER TABLE "payment_methods" DROP CONSTRAINT "FK_137bf799227505edd74b045bcce"`); - await queryRunner.query(`ALTER TABLE "payment_methods" DROP CONSTRAINT "FK_9b5a223a0b92d4804864af68e52"`); - await queryRunner.query(`ALTER TABLE "system_setting" DROP CONSTRAINT "FK_cfcbfe457665632818d9bb5f164"`); - await queryRunner.query(`ALTER TABLE "users" DROP CONSTRAINT "FK_fb02bf159ff549154df233aa6e9"`); - await queryRunner.query(`DROP TABLE "payments"`); - await queryRunner.query(`DROP TABLE "invoices"`); - await queryRunner.query(`DROP TABLE "customers"`); - await queryRunner.query(`DROP TABLE "subscription_plans"`); - await queryRunner.query(`DROP TABLE "payment_methods"`); - await queryRunner.query(`DROP INDEX "public"."IDX_34a784a7ee0ef3450ed68c08a2"`); - await queryRunner.query(`DROP TABLE "system_setting"`); - await queryRunner.query(`DROP TABLE "users"`); - await queryRunner.query(`DROP TABLE "data_lookup"`); - } - -} diff --git a/app/db/migrations/1724460259783-make_nextBillingDate_nullable.ts b/app/db/migrations/1724460259783-make_nextBillingDate_nullable.ts deleted file mode 100644 index b0f112b..0000000 --- a/app/db/migrations/1724460259783-make_nextBillingDate_nullable.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; - -export class MakeNextBillingDateNullable1724460259783 implements MigrationInterface { - name = 'MakeNextBillingDateNullable1724460259783' - - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "customers" ALTER COLUMN "nextBillingDate" DROP NOT NULL`); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "customers" ALTER COLUMN "nextBillingDate" SET NOT NULL`); - } - -} diff --git a/app/db/seeders/settings/create.runner.ts b/app/db/seeders/settings/create.runner.ts index 41e53f8..849f7f9 100644 --- a/app/db/seeders/settings/create.runner.ts +++ b/app/db/seeders/settings/create.runner.ts @@ -4,7 +4,6 @@ import { ConfigService } from '@nestjs/config'; import { config } from 'dotenv'; import CreateSeeder from "./create.seeder"; import { SystemSetting } from "../../../src/entities/system-settings.entity"; -import { DataLookup } from "../../../src/entities/data-lookup.entity"; // Load environment variables config(); @@ -16,11 +15,11 @@ const configService = new ConfigService(); const options: DataSourceOptions & SeederOptions = { type: "postgres", host: configService.get('DB_HOST'), - port: parseInt(configService.get('DB_PORT') as string), + port: parseInt(configService.get('DB_PORT')), username: configService.get('DB_USER'), password: configService.get('DB_PASSWORD'), database: configService.get('DB_NAME'), - entities: [SystemSetting, DataLookup], + entities: [SystemSetting], seeds: [CreateSeeder], }; diff --git a/app/db/seeders/settings/create.seeder.ts b/app/db/seeders/settings/create.seeder.ts index 76bdef6..e17d9db 100644 --- a/app/db/seeders/settings/create.seeder.ts +++ b/app/db/seeders/settings/create.seeder.ts @@ -3,13 +3,10 @@ import { DataSource } from "typeorm"; import * as fs from "fs"; import * as path from "path"; import { SystemSetting } from "../../../src/entities/system-settings.entity"; -import { DataLookup } from "../../../src/entities/data-lookup.entity"; -import { ObjectState } from "../../../src/utils/enums" class CreateSeeder implements Seeder { public async run(dataSource: DataSource): Promise { const repository = dataSource.getRepository(SystemSetting); - const lookupRepository = dataSource.getRepository(DataLookup) const dataPath = path.join(__dirname, "data.json"); const rawData = fs.readFileSync(dataPath, "utf8"); @@ -17,10 +14,6 @@ class CreateSeeder implements Seeder { const systemSetting: SystemSetting[] = []; - const activeObjectState = await lookupRepository.findOne( - { where: { value: ObjectState.ACTIVE } } - ) - for (const row of data) { systemSetting.push( repository.create({ @@ -28,7 +21,6 @@ class CreateSeeder implements Seeder { code: row.code, defaultValue: row.defaultValue, currentValue: row.currentValue, - objectState: activeObjectState as DataLookup }) ); } diff --git a/app/package.json b/app/package.json index 2c3d6df..ae87cb9 100644 --- a/app/package.json +++ b/app/package.json @@ -37,6 +37,7 @@ "migration:generate": "npm run typeorm -- migration:generate", "migration:run": "npm run typeorm -- migration:run", "migration:revert": "npm run typeorm -- migration:revert", + "load:fixtures": "ts-node ./node_modules/typeorm-extension/bin/cli.cjs seed:run -d db/data-source.ts", "seed:data-lookup": "ts-node ./db/seeders/data-lookup/create.runner.ts", "seed:settings": "ts-node ./db/seeders/settings/create.runner.ts" }, diff --git a/app/src/app.module.ts b/app/src/app.module.ts index 124767f..f697900 100644 --- a/app/src/app.module.ts +++ b/app/src/app.module.ts @@ -35,7 +35,6 @@ import { PaymentMethod } from './entities/payment-method.entity'; import { StripeService } from './services/stripe.service'; import { BillingService } from './services/billing.service'; import { NotificationsService } from './services/notifications.service'; -import { PaymentsController } from './controllers/payment.controller'; const config = new ConfigService(); @Module({ @@ -83,7 +82,6 @@ const config = new ConfigService(); AuthController, SystemSettingController, DataLookupController, - PaymentsController, ], providers: [ SubscriptionService, diff --git a/app/src/controllers/payment.controller.ts b/app/src/controllers/payment.controller.ts deleted file mode 100644 index a93ae60..0000000 --- a/app/src/controllers/payment.controller.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { Controller, Post, NotFoundException, InternalServerErrorException, Body } from '@nestjs/common'; -import { PaymentService } from '../services/payment.service'; -import { ApiTags } from '@nestjs/swagger'; -import { CreatePaymentDto } from '@app/dtos/payment.dto'; -import { ConfigService } from '@nestjs/config'; - -const config = new ConfigService(); - -/** - * Controller for managing payments. - */ -@ApiTags('Payment') -@Controller({ path: 'payments', version: config.get('API_VERSION') }) -export class PaymentsController { - constructor(private readonly paymentService: PaymentService) { } - - @Post('process') - async processPayment(@Body() paymentDto: CreatePaymentDto) { - try { - const paymentIntent = await this.paymentService.processNewPayment(paymentDto); - return { - success: true, - paymentIntent, - }; - } catch (error) { - if (error instanceof NotFoundException) { - throw new NotFoundException(error.message); - } - throw new InternalServerErrorException('Failed to process payment.'); - } - } -} diff --git a/app/src/controllers/webhooks.controller.ts b/app/src/controllers/webhooks.controller.ts index 72e8fef..3af0842 100644 --- a/app/src/controllers/webhooks.controller.ts +++ b/app/src/controllers/webhooks.controller.ts @@ -7,17 +7,27 @@ import { } from '@nestjs/common'; import { StripeService } from '../services/stripe.service'; import { PaymentService } from '../services/payment.service'; +import { PaymentMethodCode } from '../utils/enums'; import Stripe from 'stripe'; -import { DataSource } from 'typeorm'; +/** + * Controller to handle incoming webhooks from various services. + */ @Controller('webhooks') export class WebhooksController { constructor( private readonly stripeService: StripeService, private readonly paymentService: PaymentService, - private readonly dataSource: DataSource, // Inject DataSource for transaction management ) {} + /** + * Handles Stripe webhook events. + * + * @param payload - The raw body of the incoming Stripe webhook request. + * @param sig - The Stripe signature header used to verify the webhook. + * @returns Acknowledgment of the event receipt. + * @throws BadRequestException if the event cannot be verified. + */ @Post('stripe') async handleStripeWebhook( @Body() payload: any, @@ -36,12 +46,19 @@ export class WebhooksController { switch (event.type) { case 'checkout.session.completed': const session = event.data.object as Stripe.Checkout.Session; - await this.handleCheckoutSessionCompleted(session); + await this.paymentService.handleSuccessfulPayment( + session.subscription as string, + session.amount_total, + PaymentMethodCode.STRIPE, + ); break; case 'invoice.payment_failed': - const failedInvoice = event.data.object as Stripe.Invoice; - await this.handleInvoicePaymentFailed(failedInvoice); + const failedSession = event.data.object as Stripe.Invoice; + await this.paymentService.handleFailedPayment( + failedSession.subscription as string, + ); break; + // Handle other event types... default: console.log(`Unhandled event type ${event.type}`); } @@ -54,65 +71,4 @@ export class WebhooksController { return { received: true }; } - - private async handleCheckoutSessionCompleted(session: Stripe.Checkout.Session) { - if (session.subscription && session.amount_total !== null) { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const invoice = await this.paymentService.findInvoiceById( - session.metadata.invoiceId as string, - queryRunner.manager, // Pass the EntityManager from the QueryRunner - ); - - if (invoice) { - await this.paymentService.handleSuccessfulPayment( - invoice, - session.payment_intent as Stripe.PaymentIntent, - queryRunner.manager, // Pass the EntityManager to handleSuccessfulPayment - ); - await queryRunner.commitTransaction(); - } else { - console.error(`Invoice not found for subscription ID: ${session.subscription}`); - await queryRunner.rollbackTransaction(); - } - } catch (error) { - await queryRunner.rollbackTransaction(); - console.error( - `Failed to handle checkout session completed for subscription ID ${session.subscription}:`, - error.message, - ); - throw new BadRequestException('Failed to handle checkout session completed.'); - } finally { - await queryRunner.release(); - } - } - } - - private async handleInvoicePaymentFailed(invoice: Stripe.Invoice) { - if (invoice.subscription) { - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - await this.paymentService.handleFailedPayment( - invoice.subscription as string, - queryRunner.manager, - ); - await queryRunner.commitTransaction(); - } catch (error) { - await queryRunner.rollbackTransaction(); - console.error( - `Failed to handle failed payment for subscription ID ${invoice.subscription}:`, - error.message, - ); - throw new BadRequestException('Failed to handle failed payment.'); - } finally { - await queryRunner.release(); - } - } - } } diff --git a/app/src/dtos/payment.dto.ts b/app/src/dtos/payment.dto.ts index 1eb6ec3..ece220d 100644 --- a/app/src/dtos/payment.dto.ts +++ b/app/src/dtos/payment.dto.ts @@ -1,12 +1,12 @@ import { ApiProperty } from '@nestjs/swagger'; -import { IsUUID, IsString } from 'class-validator'; +import { IsUUID, IsNumber } from 'class-validator'; export class CreatePaymentDto { @ApiProperty({ description: 'UUID of the invoice' }) @IsUUID() invoiceId: string; - @ApiProperty({ description: 'Payment method ID' }) - @IsString() - paymentMethodId: string; + @ApiProperty({ description: 'Amount of the payment' }) + @IsNumber() + amount: number; } diff --git a/app/src/entities/base.entity.ts b/app/src/entities/base.entity.ts index 1677cd4..fe03c00 100644 --- a/app/src/entities/base.entity.ts +++ b/app/src/entities/base.entity.ts @@ -5,7 +5,6 @@ import { DeleteDateColumn, ManyToOne, BaseEntity as TypeORMBaseEntity, - JoinColumn, } from 'typeorm'; import { DataLookup } from './data-lookup.entity'; @@ -14,7 +13,6 @@ export abstract class BaseEntity extends TypeORMBaseEntity { id: string; @ManyToOne(() => DataLookup) - @JoinColumn({ name: "objectStateId" }) objectState: DataLookup; @CreateDateColumn() diff --git a/app/src/entities/customer.entity.ts b/app/src/entities/customer.entity.ts index cc8c43d..b7e3ece 100644 --- a/app/src/entities/customer.entity.ts +++ b/app/src/entities/customer.entity.ts @@ -27,6 +27,6 @@ export class CustomerSubscription extends BaseEntity { @Column({ type: 'timestamp', nullable: true }) nextRetry: Date; - @Column({ type: 'timestamp', nullable: true }) + @Column({ type: 'timestamp', nullable: false }) nextBillingDate: Date; } diff --git a/app/src/exceptions/not-found-exception.filter.ts b/app/src/exceptions/not-found-exception.filter.ts deleted file mode 100644 index e7574ad..0000000 --- a/app/src/exceptions/not-found-exception.filter.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { ExceptionFilter, Catch, ArgumentsHost, NotFoundException, HttpException, HttpStatus, Logger } from '@nestjs/common'; - -@Catch(NotFoundException) -export class NotFoundExceptionFilter implements ExceptionFilter { - private readonly logger = new Logger(NotFoundExceptionFilter.name); - - catch(exception: NotFoundException, host: ArgumentsHost) { - const ctx = host.switchToHttp(); - const response = ctx.getResponse(); - const request = ctx.getRequest(); - - const status = exception instanceof HttpException ? exception.getStatus() : HttpStatus.INTERNAL_SERVER_ERROR; - const message = exception.message || 'Resource not found'; - - // Log the error details - this.logger.error(`Not Found: ${message}`, exception.stack); - - // Respond to the client - response.status(status).json({ - statusCode: status, - timestamp: new Date().toISOString(), - path: request.url, - message: message, - }); - } -} diff --git a/app/src/main.ts b/app/src/main.ts index ad92178..d53a725 100644 --- a/app/src/main.ts +++ b/app/src/main.ts @@ -6,7 +6,6 @@ import { AllExceptionsFilter } from './exceptions/all-exceptions.filter'; import { DatabaseExceptionFilter } from './exceptions/database-exception.filter'; import { HttpExceptionFilter } from './exceptions/http-exception.filter'; import { ValidationExceptionFilter } from './exceptions/validation-exception.filter'; -import { NotFoundExceptionFilter } from './exceptions/not-found-exception.filter'; async function bootstrap() { const app = await NestFactory.create(AppModule); @@ -26,7 +25,6 @@ async function bootstrap() { new AllExceptionsFilter(), new ValidationExceptionFilter(), new DatabaseExceptionFilter(), - new NotFoundExceptionFilter() ); // Swagger configuration diff --git a/app/src/processors/billing.processor.ts b/app/src/processors/billing.processor.ts index 48c4a56..7cf2e48 100644 --- a/app/src/processors/billing.processor.ts +++ b/app/src/processors/billing.processor.ts @@ -2,7 +2,6 @@ import { Processor, Process } from '@nestjs/bull'; import { Job } from 'bull'; import { BillingService } from '../services/billing.service'; import { JobQueues } from '../utils/enums'; -import { DataSource } from 'typeorm'; /** * BillingProcessor is responsible for processing jobs related to billing, @@ -10,10 +9,7 @@ import { DataSource } from 'typeorm'; */ @Processor(JobQueues.BILLING) export class BillingProcessor { - constructor( - private readonly billingService: BillingService, - private readonly dataSource: DataSource - ) { } + constructor(private readonly billingService: BillingService) {} /** * Handles the 'generateInvoice' job from the billing queue. @@ -24,34 +20,20 @@ export class BillingProcessor { async handleGenerateInvoice(job: Job): Promise { const { subscriptionId } = job.data; - // Create a QueryRunner to manage the transaction - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - try { - await queryRunner.startTransaction(); - - const subscription = await this.billingService.getSubscriptionById(subscriptionId); + const subscription = + await this.billingService.getSubscriptionById(subscriptionId); if (subscription) { - // Pass the EntityManager from the QueryRunner to the billing service - await this.billingService.createInvoiceForSubscription(subscription, queryRunner.manager); - - // Commit the transaction if all goes well - await queryRunner.commitTransaction(); + await this.billingService.createInvoiceForSubscription(subscription); } else { // Handle case where subscription is not found console.warn(`Subscription with ID ${subscriptionId} not found.`); } } catch (error) { - // Rollback the transaction in case of an error - await queryRunner.rollbackTransaction(); console.error( `Failed to generate invoice for subscription ID ${subscriptionId}:`, error, ); - } finally { - // Release the QueryRunner whether the transaction was successful or not - await queryRunner.release(); } } } diff --git a/app/src/processors/payment.processor.ts b/app/src/processors/payment.processor.ts index bf738d5..c089c12 100644 --- a/app/src/processors/payment.processor.ts +++ b/app/src/processors/payment.processor.ts @@ -2,46 +2,40 @@ import { Processor, Process } from '@nestjs/bull'; import { Job } from 'bull'; import { PaymentService } from '../services/payment.service'; import { JobQueues } from '../utils/enums'; -import { DataSource } from 'typeorm'; +/** + * PaymentProcessor is responsible for processing payment retry jobs. + */ @Processor(JobQueues.PAYMENT_RETRY) export class PaymentProcessor { - constructor( - private readonly paymentService: PaymentService, - private readonly dataSource: DataSource, - ) {} + constructor(private readonly paymentService: PaymentService) {} + /** + * Handles the payment retry process. + * + * @param job - The Bull job containing the subscription ID and attempt number. + */ @Process() async handleRetry(job: Job): Promise { const { subscriptionId, attempt } = job.data; - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - try { - await queryRunner.startTransaction(); - - console.log(`Processing retry #${attempt} for subscription ID ${subscriptionId}`); - const result = await this.paymentService.retryPayment(subscriptionId, queryRunner.manager); + console.log( + `Processing retry #${attempt} for subscription ID ${subscriptionId}`, + ); + const result = await this.paymentService.retryPayment(subscriptionId); if (!result.success) { - console.log(`Retry #${attempt} for subscription ID ${subscriptionId} failed`); - await this.paymentService.scheduleRetry(subscriptionId, attempt + 1, queryRunner.manager); + console.log( + `Retry #${attempt} for subscription ID ${subscriptionId} failed`, + ); + await this.paymentService.scheduleRetry(subscriptionId, attempt + 1); } else { console.log(`Payment successful for subscription ID ${subscriptionId}`); - await this.paymentService.confirmPayment(subscriptionId, queryRunner.manager); + await this.paymentService.confirmPayment(subscriptionId); } - - await queryRunner.commitTransaction(); } catch (error) { - await queryRunner.rollbackTransaction(); - console.error(`Error during retry #${attempt} for subscription ID ${subscriptionId}:`, error.message); - - // It's important to handle what happens after rollback, like re-scheduling a retry attempt await this.paymentService.scheduleRetry(subscriptionId, attempt + 1); - - } finally { - await queryRunner.release(); } } } diff --git a/app/src/services/billing.service.ts b/app/src/services/billing.service.ts index 878b471..ebdbf1a 100644 --- a/app/src/services/billing.service.ts +++ b/app/src/services/billing.service.ts @@ -1,6 +1,6 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { EntityManager, Repository } from 'typeorm'; +import { Repository } from 'typeorm'; import { CustomerSubscription } from '../entities/customer.entity'; import { Invoice } from '../entities/invoice.entity'; import { DataLookup } from '../entities/data-lookup.entity'; @@ -9,7 +9,6 @@ import { InvoiceStatus, JobQueues, SubscriptionStatus } from '../utils/enums'; import { InjectQueue } from '@nestjs/bull'; import { Queue } from 'bull'; import { NotificationsService } from './notifications.service'; -import { v4 as uuidv4 } from 'uuid'; @Injectable() export class BillingService { @@ -54,12 +53,10 @@ export class BillingService { */ async createInvoiceForSubscription( subscription: CustomerSubscription, - manager: EntityManager, - ): Promise { - const invoiceStatus = await this.getInvoiceStatus(InvoiceStatus.PENDING, manager); + ): Promise { + const invoiceStatus = await this.getInvoiceStatus(InvoiceStatus.PENDING); - const invoice = manager.create(Invoice, { - code: await this.generateUniqueInvoiceCode(), + const invoice = this.invoiceRepository.create({ customerId: subscription.user.id, amount: subscription.subscriptionPlan.price, status: invoiceStatus, @@ -70,7 +67,7 @@ export class BillingService { subscription: subscription, }); - await manager.save(Invoice, invoice); + await this.invoiceRepository.save(invoice); // Send notification after invoice is generated await this.notificationsService.sendInvoiceGeneratedEmail( @@ -83,8 +80,7 @@ export class BillingService { subscription.nextBillingDate, subscription.subscriptionPlan.billingCycleDays, ); - await manager.save(CustomerSubscription, subscription); - return invoice; + await this.customerSubscriptionRepository.save(subscription); } /** @@ -209,8 +205,8 @@ export class BillingService { * @param statusValue - The value of the invoice status. * @returns The found DataLookup entity representing the status. */ - private async getInvoiceStatus(statusValue: string, manager: EntityManager): Promise { - const status = await manager.findOne(DataLookup, { + private async getInvoiceStatus(statusValue: string): Promise { + const status = await this.dataLookupRepository.findOne({ where: { value: statusValue }, }); if (!status) { @@ -260,26 +256,4 @@ export class BillingService { } return plan; } - - - async generateUniqueInvoiceCode(): Promise { - let isUnique = false; - let newCode: string; - - while (!isUnique) { - // Generate a new invoice code - const timestamp = Date.now().toString(36); // Convert timestamp to base36 - const randomString = uuidv4().split('-')[0]; // Use part of UUID for randomness - newCode = `INV-${timestamp}-${randomString}`; - - // Check if the code is unique in the database - const existingInvoice = await this.invoiceRepository.findOne({ where: { code: newCode } }); - - if (!existingInvoice) { - isUnique = true; - } - } - - return newCode; - } } diff --git a/app/src/services/payment.service.ts b/app/src/services/payment.service.ts index 2092aab..4a4a274 100644 --- a/app/src/services/payment.service.ts +++ b/app/src/services/payment.service.ts @@ -1,10 +1,6 @@ -import { - Injectable, - InternalServerErrorException, - NotFoundException, -} from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { DataSource, EntityManager, Repository } from 'typeorm'; +import { Repository } from 'typeorm'; import { Invoice } from '../entities/invoice.entity'; import { Payment } from '../entities/payment.entity'; import { CustomerSubscription } from '../entities/customer.entity'; @@ -24,7 +20,6 @@ import { InjectQueue } from '@nestjs/bull'; import { Queue } from 'bull'; import Stripe from 'stripe'; import { NotificationsService } from './notifications.service'; -import { CreatePaymentDto } from '@app/dtos/payment.dto'; @Injectable() export class PaymentService { @@ -44,236 +39,161 @@ export class PaymentService { @InjectQueue(JobQueues.PAYMENT_RETRY) private paymentRetryQueue: Queue, private readonly stripeService: StripeService, private readonly notificationsService: NotificationsService, - private readonly dataSource: DataSource, ) {} - async processNewPayment({ invoiceId, paymentMethodId }: CreatePaymentDto) { - const queryRunner = this.dataSource.createQueryRunner(); - - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const invoice = await this.findPendingInvoice(invoiceId, queryRunner.manager); - - const paymentIntent = await this.createPaymentIntent(invoice, paymentMethodId); - - await this.handlePaymentStatus(invoice, paymentIntent, queryRunner.manager); - - await queryRunner.commitTransaction(); - } catch (error) { - await queryRunner.rollbackTransaction(); - console.error('Error processing payment:', error.message); - throw new InternalServerErrorException('Failed to process payment.'); - } finally { - await queryRunner.release(); - } - } - - private async findPendingInvoice(invoiceId: string, manager: EntityManager): Promise { - const invoice = await manager.findOne(Invoice, { + /** + * Handles successful payment processing. + * + * @param subscriptionId - The ID of the subscription associated with the payment. + * @param paymentAmount - The amount paid. + * @param paymentMethodCode - The code of the payment method used. + * @throws NotFoundException if the related invoice or payment method is not found. + */ + async handleSuccessfulPayment( + subscriptionId: string, + paymentAmount: number, + paymentMethodCode: string, + ): Promise { + const invoice = await this.invoiceRepository.findOne({ where: { - id: invoiceId, + subscription: { id: subscriptionId }, status: { value: InvoiceStatus.PENDING }, }, - relations: ['subscription', 'subscription.user', 'subscription.subscriptionPlan'], + relations: ['subscription'], }); if (!invoice) { - throw new NotFoundException('Invoice not found or already been paid.'); + throw new NotFoundException( + `Invoice not found for subscription ID ${subscriptionId}`, + ); } - return invoice; - } - private async createPaymentIntent(invoice: Invoice, paymentMethodId: string): Promise { - return this.stripeService.createPaymentIntent({ - amount: Math.round(invoice.amount * 100), - currency: 'usd', - payment_method_types: ['card'], - payment_method: paymentMethodId, - description: `Payment for Invoice #${invoice.code}`, - confirm: true, - metadata: { - invoiceId: invoice.id, - invoiceCode: invoice.code, - }, + const verifiedPaymentStatus = await this.dataLookupRepository.findOne({ + where: { value: PaymentStatus.VERIFIED }, + }); + const paymentMethod = await this.paymentMethodRepository.findOne({ + where: { code: paymentMethodCode }, }); - } - - private async handlePaymentStatus( - invoice: Invoice, - paymentIntent: Stripe.PaymentIntent, - manager: EntityManager, - ) { - if (paymentIntent.status === 'succeeded') { - await this.handleSuccessfulPayment(invoice, paymentIntent, manager); - console.log('Payment succeeded, invoice marked as PAID.'); - } else { - await this.handleFailedPayment(invoice.subscription.id, manager); - } - } - - async handleSuccessfulPayment( - invoice: Invoice, - paymentIntent: Stripe.PaymentIntent, - manager: EntityManager, - ) { - if (!invoice.subscription?.user) { - throw new NotFoundException('User or subscription not found.'); - } - - await this.saveSuccessfulPayment(invoice, paymentIntent, manager); - await this.updateInvoiceStatus(invoice, InvoiceStatus.PAID, manager); - await this.updateSubscriptionStatus(invoice, SubscriptionStatus.ACTIVE, manager); - await this.notificationsService.sendPaymentSuccessEmail( - invoice.subscription.user.email, - invoice.subscription.subscriptionPlan.name, - ); - } - - private async saveSuccessfulPayment( - invoice: Invoice, - paymentIntent: Stripe.PaymentIntent, - manager: EntityManager, - ) { - const verifiedPaymentStatus = await this.findDataLookupByValue(PaymentStatus.VERIFIED, manager); - const paymentMethod = await this.findPaymentMethodByCode(PaymentMethodCode.STRIPE, manager); - const payment = manager.create(Payment, { + const payment = this.paymentRepository.create({ invoice, paymentMethod, status: verifiedPaymentStatus, - amount: invoice.amount, - referenceNumber: paymentIntent.id, - payerName: this.getCustomerInfo(paymentIntent.customer), + amount: paymentAmount, paymentDate: new Date().toISOString(), }); - await manager.save(Payment, payment); - } + await this.paymentRepository.save(payment); - private async updateInvoiceStatus( - invoice: Invoice, - statusValue: InvoiceStatus, - manager: EntityManager, - ) { - const status = await this.findDataLookupByValue(statusValue, manager); - invoice.status = status; + const paidInvoiceStatus = await this.dataLookupRepository.findOne({ + where: { value: InvoiceStatus.PAID }, + }); + invoice.status = paidInvoiceStatus; invoice.paymentDate = new Date(); - await manager.save(Invoice, invoice); - } - - private async updateSubscriptionStatus( - invoice: Invoice, - subscriptionStatus: SubscriptionStatus, - manager: EntityManager, - ) { - const status = await this.findDataLookupByValue(subscriptionStatus, manager); - const subscription = invoice.subscription; - subscription.subscriptionStatus = status; - await manager.save(CustomerSubscription, subscription); - } + await this.invoiceRepository.save(invoice); - private async findDataLookupByValue(value: string, manager: EntityManager): Promise { - return manager.findOne(DataLookup, { where: { value } }); - } - - private async findPaymentMethodByCode(code: string, manager: EntityManager): Promise { - return manager.findOne(PaymentMethod, { where: { code } }); + await this.notificationsService.sendPaymentSuccessEmail( + invoice.subscription.user.email, + invoice.subscription.subscriptionPlan.name, + ); } - async handleFailedPayment(subscriptionId: string, manager: EntityManager): Promise { - try { - const subscription = await this.findSubscriptionById(subscriptionId, manager); - if (!subscription) { - throw new NotFoundException(`Subscription with ID ${subscriptionId} not found.`); - } - - const overdueStatus = await this.findDataLookupByValue(SubscriptionStatus.OVERDUE, manager); - - subscription.subscriptionStatus = overdueStatus; - await manager.save(CustomerSubscription, subscription); + /** + * Handles failed payment processing. + * + * @param subscriptionId - The ID of the subscription associated with the failed payment. + * @throws NotFoundException if the subscription is not found. + */ + async handleFailedPayment(subscriptionId: string): Promise { + const subscription = await this.customerSubscriptionRepository.findOne({ + where: { id: subscriptionId }, + relations: ['subscriptionStatus'], + }); - await this.notificationsService.sendPaymentFailureEmail( - subscription.user.email, - subscription.subscriptionPlan.name, + if (!subscription) { + throw new NotFoundException( + `Subscription with ID ${subscriptionId} not found`, ); + } - await this.scheduleRetry(subscriptionId, 1, manager); + const overdueStatus = await this.dataLookupRepository.findOne({ + where: { + type: SubscriptionStatus.TYPE, + value: SubscriptionStatus.OVERDUE, + }, + }); - } catch (error) { - if (error instanceof NotFoundException) { - throw error; // Re-throw specific known exceptions - } - console.error(`Failed to handle failed payment for subscription ID ${subscriptionId}:`, error); - throw new InternalServerErrorException('Failed to handle failed payment.'); - } + subscription.subscriptionStatus = overdueStatus; + await this.customerSubscriptionRepository.save(subscription); + + await this.notificationsService.sendPaymentFailureEmail( + subscription.user.email, + subscription.subscriptionPlan.name, + ); + await this.scheduleRetry(subscriptionId); } - private async findSubscriptionById(subscriptionId: string, manager: EntityManager): Promise { - const subscription = await manager.findOne(CustomerSubscription, { + /** + * Schedules a retry for a failed payment. + * + * @param subscriptionId - The ID of the subscription to retry payment for. + * @param attempt - The current retry attempt number. + * @throws NotFoundException if the subscription is not found. + */ + async scheduleRetry(subscriptionId: string, attempt = 1): Promise { + const subscription = await this.customerSubscriptionRepository.findOne({ where: { id: subscriptionId }, - relations: ['subscriptionStatus'], }); if (!subscription) { - throw new NotFoundException(`Subscription with ID ${subscriptionId} not found`); + throw new NotFoundException( + `Subscription with ID ${subscriptionId} not found`, + ); } - return subscription; - } - - async scheduleRetry(subscriptionId: string, attempt: number, manager?: EntityManager): Promise { - const subscription = await this.findSubscriptionById(subscriptionId, manager); - const maxRetries = await this.getSystemSetting(PaymentRetrySettings.MAX_RETRIES, manager); - if (subscription.retryCount >= parseInt(maxRetries.currentValue)) { - console.log(`Subscription ID ${subscription.id} has reached the maximum number of retries.`); + const maxRetriesSetting = await this.settingRepository.findOne({ + where: { code: PaymentRetrySettings.MAX_RETRIES }, + }); + if (subscription.retryCount >= parseInt(maxRetriesSetting.currentValue)) { + console.log( + `Subscription ID ${subscription.id} has reached the maximum number of retries.`, + ); return; } - const retryDelay = await this.getSystemSetting(PaymentRetrySettings.RETRY_DELAY_MINUTES, manager); - const nextRun = parseInt(retryDelay.currentValue) * 60 * 1000; + const retryDelaySetting = await this.settingRepository.findOne({ + where: { code: PaymentRetrySettings.RETRY_DELAY_MINUTES }, + }); + const nextRun = parseInt(retryDelaySetting.currentValue) * 60 * 1000; - await this.paymentRetryQueue.add({ subscriptionId, attempt }, { delay: nextRun }); + await this.paymentRetryQueue.add( + { + subscriptionId, + attempt, + }, + { + delay: nextRun, + }, + ); subscription.retryCount = attempt; subscription.nextRetry = new Date(Date.now() + nextRun); - if (manager) { - await manager.save(CustomerSubscription, subscription); - } else { - await this.customerSubscriptionRepository.save(subscription); - } - - console.log(`Scheduled retry #${subscription.retryCount} for subscription ID ${subscription.id} at ${subscription.nextRetry}`); - } + await this.customerSubscriptionRepository.save(subscription); - - private async getSystemSetting(code: string, manager?: EntityManager): Promise { - return manager.findOne(SystemSetting, { - where: { code } - }); - } - - async retryPayment(subscriptionId: string, manager: EntityManager): Promise<{ success: boolean }> { - const invoice = await this.findFailedInvoice(subscriptionId, manager); - - if (!invoice) { - throw new NotFoundException(`No unpaid invoice found for subscription ID ${subscriptionId}`); - } - - const paymentIntent = await this.createRetryPaymentIntent(invoice); - - if (paymentIntent.status === 'succeeded') { - await this.updateInvoiceStatus(invoice, InvoiceStatus.PAID, manager); - await this.saveSuccessfulPayment(invoice, paymentIntent, manager); - return { success: true }; - } else { - return { success: false }; - } + console.log( + `Scheduled retry #${subscription.retryCount} for subscription ID ${subscription.id} at ${subscription.nextRetry}`, + ); } - private async findFailedInvoice(subscriptionId: string, manager: EntityManager): Promise { - const invoice = await manager.findOne(Invoice, { + /** + * Attempts to retry a failed payment. + * + * @param subscriptionId - The ID of the subscription to retry payment for. + * @returns An object indicating the success status of the payment retry. + * @throws NotFoundException if no unpaid invoice is found for the subscription. + */ + async retryPayment(subscriptionId: string): Promise<{ success: boolean }> { + const invoice = await this.invoiceRepository.findOne({ where: { subscription: { id: subscriptionId }, status: { value: InvoiceStatus.FAILED }, @@ -282,36 +202,73 @@ export class PaymentService { }); if (!invoice) { - throw new NotFoundException(`No unpaid invoice found for subscription ID ${subscriptionId}`); + throw new NotFoundException( + `No unpaid invoice found for subscription ID ${subscriptionId}`, + ); } - return invoice; - } - private async createRetryPaymentIntent(invoice: Invoice): Promise { - return this.stripeService.createPaymentIntent({ - amount: Math.round(invoice.amount * 100), - currency: 'usd', - payment_method_types: ['card'], - description: `Payment for Invoice #${invoice.id}`, - metadata: { - invoiceId: invoice.id, - subscriptionId: invoice.subscription.id, - }, - }); - } - - async confirmPayment(subscriptionId: string, manager: EntityManager): Promise { - const subscription = await this.findSubscriptionById(subscriptionId, manager); - const activeStatus = await this.findDataLookupByValue(SubscriptionStatus.ACTIVE, manager); - - if (!activeStatus) { - throw new NotFoundException('Active status not found.'); + try { + const paymentIntent = await this.stripeService.createPaymentIntent({ + amount: Math.round(invoice.amount * 100), + currency: 'usd', + payment_method_types: ['card'], + description: `Payment for Invoice #${invoice.id}`, + metadata: { + invoiceId: invoice.id, + subscriptionId: subscriptionId, + }, + }); + + if (paymentIntent.status === 'succeeded') { + const paidStatus = await this.dataLookupRepository.findOne({ + where: { value: InvoiceStatus.PAID }, + }); + invoice.status = paidStatus; + invoice.paymentDate = new Date(); + await this.invoiceRepository.save(invoice); + + const paymentMethod = await this.getDefaultPaymentMethod(); + const payment = this.paymentRepository.create({ + amount: invoice.amount, + status: paidStatus, + invoice: invoice, + paymentMethod: paymentMethod, + referenceNumber: paymentIntent.id, + payerName: this.getCustomerInfo(paymentIntent.customer), + paymentDate: invoice.paymentDate.toISOString(), + }); + await this.paymentRepository.save(payment); + + return { success: true }; + } else { + return { success: false }; + } + } catch (error) { + console.error( + `Failed to process payment for invoice ID ${invoice.id}:`, + error, + ); + return { success: false }; + } } - subscription.subscriptionStatus = activeStatus; - await manager.save(CustomerSubscription, subscription); + /** + * Retrieves the default payment method for the system. + * + * @returns The PaymentMethod entity corresponding to the default payment method. + */ + async getDefaultPaymentMethod(): Promise { + return this.paymentMethodRepository.findOne({ + where: { code: PaymentMethodCode.STRIPE }, + }); } + /** + * Extracts customer information from the Stripe customer object. + * + * @param customer - The Stripe customer object or customer ID. + * @returns The customer's name or a default value if not available. + */ getCustomerInfo( customer: string | Stripe.Customer | Stripe.DeletedCustomer | null, ): string | null { @@ -326,13 +283,36 @@ export class PaymentService { return 'Stripe Customer'; } - async findInvoiceById(invoiceId: string, manager: EntityManager): Promise { - return manager.findOne(Invoice, { + /** + * Confirms a successful payment and updates the subscription status to active. + * + * @param subscriptionId - The ID of the subscription to confirm payment for. + * @throws NotFoundException if the subscription or active status is not found. + */ + async confirmPayment(subscriptionId: string): Promise { + const subscription = await this.customerSubscriptionRepository.findOne({ + where: { id: subscriptionId }, + relations: ['subscriptionStatus'], + }); + + if (!subscription) { + throw new NotFoundException( + `Subscription with ID ${subscriptionId} not found`, + ); + } + + const activeStatus = await this.dataLookupRepository.findOne({ where: { - id: invoiceId, - status: { value: InvoiceStatus.PENDING }, + type: SubscriptionStatus.TYPE, + value: SubscriptionStatus.ACTIVE, }, - relations: ['subscription'], }); + + if (!activeStatus) { + throw new NotFoundException(`Active status not found.`); + } + + subscription.subscriptionStatus = activeStatus; + await this.customerSubscriptionRepository.save(subscription); } } diff --git a/app/src/services/subscription.service.ts b/app/src/services/subscription.service.ts index 53700fd..4eaa1be 100644 --- a/app/src/services/subscription.service.ts +++ b/app/src/services/subscription.service.ts @@ -1,4 +1,4 @@ -import { Injectable, InternalServerErrorException, NotFoundException } from '@nestjs/common'; +import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository } from 'typeorm'; import { CustomerSubscription } from '../entities/customer.entity'; @@ -17,8 +17,6 @@ import { SubscriptionStatus, } from '../utils/enums'; import { GenericService } from './base.service'; -import { BillingService } from './billing.service'; -import { PaymentService } from './payment.service'; @Injectable() export class SubscriptionService extends GenericService { @@ -31,8 +29,6 @@ export class SubscriptionService extends GenericService { private readonly subscriptionPlanRepository: Repository, @InjectRepository(DataLookup) private readonly dataLookupRepository: Repository, - private readonly billingService: BillingService, - private readonly paymentService: PaymentService, dataSource: DataSource, ) { super(SubscriptionPlan, dataSource); @@ -50,54 +46,41 @@ export class SubscriptionService extends GenericService { ): Promise { const { userId, subscriptionPlanId } = createSubscriptionDto; - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); - await queryRunner.startTransaction(); - - try { - const user = await queryRunner.manager.findOne(this.userRepository.target, { where: { id: userId } }); - if (!user) { - throw new NotFoundException(`User with ID ${userId} not found`); - } - - const subscriptionPlan = await queryRunner.manager.findOne(this.subscriptionPlanRepository.target, { - where: { id: subscriptionPlanId, status: { value: SubscriptionPlanState.ACTIVE } } - }); - if (!subscriptionPlan) { - throw new NotFoundException(`Subscription plan with ID ${subscriptionPlanId} not found.`); - } - - const subscriptionStatus = await queryRunner.manager.findOne(this.dataLookupRepository.target, { - where: { value: SubscriptionStatus.PENDING }, - }); - if (!subscriptionStatus) { - throw new NotFoundException(`Unable to get default subscription status. Please load fixtures`); - } - - const startDate = new Date(); - - const newSubscription = this.customerSubscriptionRepository.create({ - user, - subscriptionPlan, - subscriptionStatus, - endDate: null, - startDate, - }); + const user = await this.userRepository.findOneBy({ id: userId }); + if (!user) { + throw new NotFoundException(`User with ID ${userId} not found`); + } - await queryRunner.manager.save(newSubscription); + const subscriptionPlan = await this.subscriptionPlanRepository.findOneBy({ + id: subscriptionPlanId, + }); + if (!subscriptionPlan) { + throw new NotFoundException( + `Subscription plan with ID ${subscriptionPlanId} not found`, + ); + } - // Create and save invoice within the same transaction - const invoice = await this.billingService.createInvoiceForSubscription(newSubscription, queryRunner.manager); + const subscriptionStatus = await this.dataLookupRepository.findOneBy({ + value: SubscriptionStatus.PENDING, + }); + if (!subscriptionStatus) { + throw new NotFoundException( + `Unable to get default subscription status. Please load fixtures`, + ); + } - await queryRunner.commitTransaction(); - return { ...newSubscription, invoice } as unknown as CustomerSubscription; + const newSubscription = this.customerSubscriptionRepository.create({ + user, + subscriptionPlan, + subscriptionStatus, + endDate: null, + startDate: Date.now(), + nextBillingDate: new Date( + Date.now() + subscriptionPlan.billingCycleDays * 24 * 60 * 60 * 1000, + ).getTime(), + }); - } catch (error) { - await queryRunner.rollbackTransaction(); - throw new InternalServerErrorException('Transaction failed. All operations rolled back.', error.message); - } finally { - await queryRunner.release(); - } + return this.customerSubscriptionRepository.save(newSubscription); } /** @@ -110,8 +93,13 @@ export class SubscriptionService extends GenericService { async getCustomerSubscriptions( userId: string, ): Promise { + const user = await this.userRepository.findOneBy({ id: userId }); + if (!user) { + throw new NotFoundException(`User with ID ${userId} not found`); + } + return this.customerSubscriptionRepository.find({ - where: { user: { id: userId } }, + where: { user }, relations: ['subscriptionPlan', 'subscriptionStatus'], }); } diff --git a/app/tests/controllers/webhooks.controller.spec.ts b/app/tests/controllers/webhooks.controller.spec.ts index daee075..8bc8fb1 100644 --- a/app/tests/controllers/webhooks.controller.spec.ts +++ b/app/tests/controllers/webhooks.controller.spec.ts @@ -2,59 +2,38 @@ import { Test, TestingModule } from '@nestjs/testing'; import { WebhooksController } from '../../src/controllers/webhooks.controller'; import { StripeService } from '../../src/services/stripe.service'; import { PaymentService } from '../../src/services/payment.service'; -import { DataSource, QueryRunner, EntityManager } from 'typeorm'; -import { BadRequestException } from '@nestjs/common'; +import { PaymentMethodCode } from '../../src/utils/enums'; import Stripe from 'stripe'; describe('WebhooksController', () => { let controller: WebhooksController; let stripeService: StripeService; let paymentService: PaymentService; - let dataSource: DataSource; - let queryRunner: QueryRunner; beforeEach(async () => { - queryRunner = { - connect: jest.fn(), - startTransaction: jest.fn(), - commitTransaction: jest.fn(), - rollbackTransaction: jest.fn(), - release: jest.fn(), - manager: {} as EntityManager, - } as unknown as QueryRunner; - - dataSource = { - createQueryRunner: jest.fn().mockReturnValue(queryRunner), - } as unknown as DataSource; - - const module: TestingModule = await Test.createTestingModule({ - controllers: [WebhooksController], - providers: [ - { - provide: StripeService, - useValue: { - constructEvent: jest.fn(), - }, - }, - { - provide: PaymentService, - useValue: { - findInvoiceById: jest.fn(), - handleSuccessfulPayment: jest.fn(), - handleFailedPayment: jest.fn(), - }, - }, - { - provide: DataSource, - useValue: dataSource, - }, - ], - }).compile(); - - controller = module.get(WebhooksController); - stripeService = module.get(StripeService); - paymentService = module.get(PaymentService); - }); + const module: TestingModule = await Test.createTestingModule({ + controllers: [WebhooksController], + providers: [ + { + provide: StripeService, + useValue: { + constructEvent: jest.fn(), + }, + }, + { + provide: PaymentService, + useValue: { + handleSuccessfulPayment: jest.fn(), + handleFailedPayment: jest.fn(), + }, + }, + ], + }).compile(); + + controller = module.get(WebhooksController); + stripeService = module.get(StripeService); + paymentService = module.get(PaymentService); + }); it('should be defined', () => { expect(controller).toBeDefined(); @@ -64,139 +43,68 @@ describe('WebhooksController', () => { it('should handle checkout.session.completed event', async () => { const payload = { /* mock payload */ }; const signature = 'mock-signature'; - const mockSession = { - subscription: 'sub_12345', - amount_total: 2000, - metadata: { - invoiceId: 'inv_12345', - }, - payment_intent: 'pi_12345', - } as unknown as Stripe.Checkout.Session; - - const mockEvent = { - type: 'checkout.session.completed', - data: { - object: mockSession, - }, - } as Stripe.Event; - - jest.spyOn(stripeService, 'constructEvent').mockReturnValue(mockEvent); - jest.spyOn(paymentService, 'findInvoiceById').mockResolvedValue({} as any); - - await controller.handleStripeWebhook(payload, signature); - - expect(stripeService.constructEvent).toHaveBeenCalledWith(payload, signature); - expect(queryRunner.startTransaction).toHaveBeenCalled(); - expect(paymentService.findInvoiceById).toHaveBeenCalledWith( - 'inv_12345', - queryRunner.manager, - ); - expect(paymentService.handleSuccessfulPayment).toHaveBeenCalledWith( - expect.any(Object), // The found invoice - mockSession.payment_intent, - queryRunner.manager, - ); - expect(queryRunner.commitTransaction).toHaveBeenCalled(); - }); - - it('should handle invoice.payment_failed event', async () => { - const payload = { /* mock payload */ }; - const signature = 'mock-signature'; - const mockInvoice = { - subscription: 'sub_12345', - } as unknown as Stripe.Invoice; - - const mockEvent = { - type: 'invoice.payment_failed', - data: { - object: mockInvoice, - }, - } as Stripe.Event; - - jest.spyOn(stripeService, 'constructEvent').mockReturnValue(mockEvent); - - await controller.handleStripeWebhook(payload, signature); - - expect(stripeService.constructEvent).toHaveBeenCalledWith(payload, signature); - expect(queryRunner.startTransaction).toHaveBeenCalled(); - expect(paymentService.handleFailedPayment).toHaveBeenCalledWith( - 'sub_12345', - queryRunner.manager, - ); - expect(queryRunner.commitTransaction).toHaveBeenCalled(); - }); - - it('should rollback transaction on error during checkout.session.completed', async () => { - const payload = { /* mock payload */ }; - const signature = 'mock-signature'; - const mockSession = { - subscription: 'sub_12345', - amount_total: 2000, - metadata: { - invoiceId: 'inv_12345', - }, - payment_intent: 'pi_12345', - } as unknown as Stripe.Checkout.Session; - - const mockEvent = { - type: 'checkout.session.completed', - data: { - object: mockSession, - }, - } as Stripe.Event; - - jest.spyOn(stripeService, 'constructEvent').mockReturnValue(mockEvent); - jest.spyOn(paymentService, 'findInvoiceById').mockRejectedValue(new Error('Test Error')); - - await expect(controller.handleStripeWebhook(payload, signature)).rejects.toThrow(BadRequestException); - - expect(queryRunner.rollbackTransaction).toHaveBeenCalled(); - expect(queryRunner.commitTransaction).not.toHaveBeenCalled(); - }); - - it('should rollback transaction on error during invoice.payment_failed', async () => { - const payload = { /* mock payload */ }; - const signature = 'mock-signature'; - const mockInvoice = { - subscription: 'sub_12345', - } as unknown as Stripe.Invoice; - - const mockEvent = { - type: 'invoice.payment_failed', - data: { - object: mockInvoice, - }, - } as Stripe.Event; + const mockEvent = { + type: 'checkout.session.completed', + data: { + object: { + subscription: 'sub_12345', + amount_total: 2000, + }, + }, + } as Stripe.Event; + + jest.spyOn(stripeService, 'constructEvent').mockReturnValue(mockEvent); + + await controller.handleStripeWebhook(payload, signature); + + expect(stripeService.constructEvent).toHaveBeenCalledWith(payload, signature); + expect(paymentService.handleSuccessfulPayment).toHaveBeenCalledWith( + 'sub_12345', + 2000, + PaymentMethodCode.STRIPE, + ); + }); + + it('should handle invoice.payment_failed event', async () => { + const payload = { /* mock payload */ }; + const signature = 'mock-signature'; + const mockEvent = { + type: 'invoice.payment_failed', + data: { + object: { + subscription: 'sub_12345', + }, + }, + } as Stripe.Event; - jest.spyOn(stripeService, 'constructEvent').mockReturnValue(mockEvent); - jest.spyOn(paymentService, 'handleFailedPayment').mockRejectedValue(new Error('Test Error')); + jest.spyOn(stripeService, 'constructEvent').mockReturnValue(mockEvent); - await expect(controller.handleStripeWebhook(payload, signature)).rejects.toThrow(BadRequestException); + await controller.handleStripeWebhook(payload, signature); - expect(queryRunner.rollbackTransaction).toHaveBeenCalled(); - expect(queryRunner.commitTransaction).not.toHaveBeenCalled(); - }); + expect(stripeService.constructEvent).toHaveBeenCalledWith(payload, signature); + expect(paymentService.handleFailedPayment).toHaveBeenCalledWith('sub_12345'); + }); - it('should handle unhandled event types gracefully', async () => { - const payload = { /* mock payload */ }; - const signature = 'mock-signature'; - const mockEvent = { - type: 'unknown.event', - data: { - object: {}, - }, - } as unknown as Stripe.Event; + it('should handle unhandled event types gracefully', async () => { + const payload = { /* mock payload */ }; + const signature = 'mock-signature'; + const mockEvent = { + type: 'unknown.event', + data: { + object: {}, + }, + } as unknown as Stripe.Event; - jest.spyOn(stripeService, 'constructEvent').mockReturnValue(mockEvent); - const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + jest.spyOn(stripeService, 'constructEvent').mockReturnValue(mockEvent); + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(); - const result = await controller.handleStripeWebhook(payload, signature); + const result = await controller.handleStripeWebhook(payload, signature); - expect(stripeService.constructEvent).toHaveBeenCalledWith(payload, signature); - expect(consoleSpy).toHaveBeenCalledWith('Unhandled event type unknown.event'); - expect(result).toEqual({ received: true }); + expect(stripeService.constructEvent).toHaveBeenCalledWith(payload, signature); + expect(consoleSpy).toHaveBeenCalledWith('Unhandled event type unknown.event'); + expect(result).toEqual({ received: true }); - consoleSpy.mockRestore(); + consoleSpy.mockRestore(); + }); }); - }); }); diff --git a/app/tests/processors/billing.processor.spec.ts b/app/tests/processors/billing.processor.spec.ts index e7d3a76..141883d 100644 --- a/app/tests/processors/billing.processor.spec.ts +++ b/app/tests/processors/billing.processor.spec.ts @@ -1,40 +1,14 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BillingProcessor } from '../../src/processors/billing.processor'; import { BillingService } from '../../src/services/billing.service'; -import { DataSource, QueryRunner, EntityManager } from 'typeorm'; import { Job } from 'bull'; import { CustomerSubscription } from '@app/entities/customer.entity'; describe('BillingProcessor', () => { let billingProcessor: BillingProcessor; let billingService: BillingService; - let dataSource: DataSource; - let queryRunner: QueryRunner; - let manager: EntityManager; beforeEach(async () => { - // Mock the EntityManager - manager = { - findOne: jest.fn(), - save: jest.fn(), - create: jest.fn(), - } as unknown as EntityManager; - - // Mock the QueryRunner - queryRunner = { - connect: jest.fn(), - startTransaction: jest.fn(), - commitTransaction: jest.fn(), - rollbackTransaction: jest.fn(), - release: jest.fn(), - manager, // Attach the mocked manager - } as unknown as QueryRunner; - - // Mock the DataSource - dataSource = { - createQueryRunner: jest.fn().mockReturnValue(queryRunner), - } as unknown as DataSource; - const module: TestingModule = await Test.createTestingModule({ providers: [ BillingProcessor, @@ -45,10 +19,6 @@ describe('BillingProcessor', () => { createInvoiceForSubscription: jest.fn(), }, }, - { - provide: DataSource, - useValue: dataSource, - }, ], }).compile(); @@ -74,13 +44,12 @@ describe('BillingProcessor', () => { } as CustomerSubscription; jest.spyOn(billingService, 'getSubscriptionById').mockResolvedValue(mockSubscription); - const createInvoiceSpy = jest.spyOn(billingService, 'createInvoiceForSubscription').mockResolvedValue(undefined); + const createInvoiceSpy = jest.spyOn(billingService, 'createInvoiceForSubscription').mockResolvedValue(); await billingProcessor.handleGenerateInvoice(mockJob); expect(billingService.getSubscriptionById).toHaveBeenCalledWith('valid-subscription-id'); - expect(createInvoiceSpy).toHaveBeenCalledWith(mockSubscription, expect.any(Object)); // Check it was called with the subscription and manager - expect(queryRunner.commitTransaction).toHaveBeenCalled(); // Ensure transaction was committed + expect(createInvoiceSpy).toHaveBeenCalledWith(mockSubscription); }); it('should not generate an invoice if subscription does not exist', async () => { @@ -97,7 +66,6 @@ describe('BillingProcessor', () => { expect(billingService.getSubscriptionById).toHaveBeenCalledWith('invalid-subscription-id'); expect(createInvoiceSpy).not.toHaveBeenCalled(); - expect(queryRunner.rollbackTransaction).not.toHaveBeenCalled(); // Ensure no rollback since no error }); }); }); diff --git a/app/tests/processors/payment.processor.spec.ts b/app/tests/processors/payment.processor.spec.ts index ff45379..5f8bce4 100644 --- a/app/tests/processors/payment.processor.spec.ts +++ b/app/tests/processors/payment.processor.spec.ts @@ -1,51 +1,30 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PaymentProcessor } from '../../src/processors/payment.processor'; import { PaymentService } from '../../src/services/payment.service'; -import { DataSource, QueryRunner, EntityManager } from 'typeorm'; +import { Job } from 'bull'; describe('PaymentProcessor', () => { let paymentProcessor: PaymentProcessor; - let paymentService: jest.Mocked; - let dataSource: jest.Mocked; - let queryRunner: jest.Mocked; - let manager: jest.Mocked; + let paymentService: PaymentService; beforeEach(async () => { - manager = { - findOne: jest.fn(), - save: jest.fn(), - create: jest.fn(), - } as unknown as jest.Mocked; - - queryRunner = { - connect: jest.fn(), - startTransaction: jest.fn(), - commitTransaction: jest.fn(), - rollbackTransaction: jest.fn(), - release: jest.fn(), - manager, // Attach the mocked manager to the query runner - } as unknown as jest.Mocked; - - dataSource = { - createQueryRunner: jest.fn().mockReturnValue(queryRunner), - } as unknown as jest.Mocked; - - paymentService = { - retryPayment: jest.fn(), - scheduleRetry: jest.fn(), - confirmPayment: jest.fn(), - } as unknown as jest.Mocked; - - const module: TestingModule = await Test.createTestingModule({ - providers: [ - PaymentProcessor, - { provide: PaymentService, useValue: paymentService }, - { provide: DataSource, useValue: dataSource }, - ], - }).compile(); - - paymentProcessor = module.get(PaymentProcessor); - }); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + PaymentProcessor, + { + provide: PaymentService, + useValue: { + retryPayment: jest.fn(), + scheduleRetry: jest.fn(), + confirmPayment: jest.fn(), + }, + }, + ], + }).compile(); + + paymentProcessor = module.get(PaymentProcessor); + paymentService = module.get(PaymentService); + }); it('should be defined', () => { expect(paymentProcessor).toBeDefined(); @@ -53,44 +32,57 @@ describe('PaymentProcessor', () => { describe('handleRetry', () => { it('should confirm payment if retry is successful', async () => { - const job = { - data: { subscriptionId: 'sub_123', attempt: 1 }, - } as any; - - paymentService.retryPayment.mockResolvedValue({ success: true }); - - await paymentProcessor.handleRetry(job); - - expect(paymentService.confirmPayment).toHaveBeenCalledWith('sub_123', manager); - expect(queryRunner.commitTransaction).toHaveBeenCalled(); - }); - - it('should schedule another retry if retry fails', async () => { - const job = { - data: { subscriptionId: 'sub_123', attempt: 1 }, - } as any; - - paymentService.retryPayment.mockResolvedValue({ success: false }); - - await paymentProcessor.handleRetry(job); - - expect(paymentService.scheduleRetry).toHaveBeenCalledWith('sub_123', 2, manager); - expect(queryRunner.commitTransaction).toHaveBeenCalled(); - }); - - it('should schedule another retry if an error occurs', async () => { - const job = { - data: { subscriptionId: 'sub_123', attempt: 1 }, - } as any; - - const error = new Error('Test error'); - paymentService.retryPayment.mockRejectedValue(error); - - await paymentProcessor.handleRetry(job); - - expect(paymentService.scheduleRetry).toHaveBeenCalledWith('sub_123', 2); - expect(queryRunner.rollbackTransaction).toHaveBeenCalled(); - expect(queryRunner.release).toHaveBeenCalled(); + const mockJob = { + data: { + subscriptionId: 'valid-subscription-id', + attempt: 1, + }, + } as Job; + + jest.spyOn(paymentService, 'retryPayment').mockResolvedValue({ success: true }); + const confirmPaymentSpy = jest.spyOn(paymentService, 'confirmPayment'); + + await paymentProcessor.handleRetry(mockJob); + + expect(paymentService.retryPayment).toHaveBeenCalledWith('valid-subscription-id'); + expect(confirmPaymentSpy).toHaveBeenCalledWith('valid-subscription-id'); + expect(paymentService.scheduleRetry).not.toHaveBeenCalled(); + }); + + it('should schedule another retry if retry fails', async () => { + const mockJob = { + data: { + subscriptionId: 'valid-subscription-id', + attempt: 1, + }, + } as Job; + + jest.spyOn(paymentService, 'retryPayment').mockResolvedValue({ success: false }); + const scheduleRetrySpy = jest.spyOn(paymentService, 'scheduleRetry'); + + await paymentProcessor.handleRetry(mockJob); + + expect(paymentService.retryPayment).toHaveBeenCalledWith('valid-subscription-id'); + expect(scheduleRetrySpy).toHaveBeenCalledWith('valid-subscription-id', 2); + expect(paymentService.confirmPayment).not.toHaveBeenCalled(); + }); + + it('should schedule another retry if an error occurs', async () => { + const mockJob = { + data: { + subscriptionId: 'valid-subscription-id', + attempt: 1, + }, + } as Job; + + jest.spyOn(paymentService, 'retryPayment').mockRejectedValue(new Error('Payment error')); + const scheduleRetrySpy = jest.spyOn(paymentService, 'scheduleRetry'); + + await paymentProcessor.handleRetry(mockJob); + + expect(paymentService.retryPayment).toHaveBeenCalledWith('valid-subscription-id'); + expect(scheduleRetrySpy).toHaveBeenCalledWith('valid-subscription-id', 2); + expect(paymentService.confirmPayment).not.toHaveBeenCalled(); + }); }); - }); }); diff --git a/app/tests/services/billing.service.spec.ts b/app/tests/services/billing.service.spec.ts index e6371ca..d432cdf 100644 --- a/app/tests/services/billing.service.spec.ts +++ b/app/tests/services/billing.service.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { BillingService } from '../../src/services/billing.service'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository, EntityManager } from 'typeorm'; +import { Repository } from 'typeorm'; import { CustomerSubscription } from '../../src/entities/customer.entity'; import { Invoice } from '../../src/entities/invoice.entity'; import { DataLookup } from '../../src/entities/data-lookup.entity'; @@ -20,15 +20,8 @@ describe('BillingService', () => { let subscriptionPlanRepository: jest.Mocked>; let billingQueue: jest.Mocked; let notificationsService: jest.Mocked; - let manager: jest.Mocked; beforeEach(async () => { - manager = { - create: jest.fn(), - save: jest.fn(), - findOne: jest.fn(), - } as unknown as jest.Mocked; - const module: TestingModule = await Test.createTestingModule({ providers: [ BillingService, @@ -118,14 +111,13 @@ describe('BillingService', () => { const mockPendingStatus = { value: InvoiceStatus.PENDING } as DataLookup; const mockInvoice = { id: 'invoice1' } as Invoice; - manager.findOne.mockResolvedValue(mockPendingStatus); - manager.create.mockReturnValue(mockInvoice as any); - manager.save.mockResolvedValue(mockInvoice); + dataLookupRepository.findOne.mockResolvedValue(mockPendingStatus); + invoiceRepository.create.mockReturnValue(mockInvoice); + invoiceRepository.save.mockResolvedValue(mockInvoice); - await service.createInvoiceForSubscription(mockSubscription, manager); + await service.createInvoiceForSubscription(mockSubscription); - expect(manager.create).toHaveBeenCalledWith(Invoice, { - code: expect.any(String), + expect(invoiceRepository.create).toHaveBeenCalledWith({ customerId: 'user1', amount: 100, status: mockPendingStatus, @@ -133,9 +125,9 @@ describe('BillingService', () => { subscription: mockSubscription, }); - expect(manager.save).toHaveBeenCalledWith(Invoice, mockInvoice); + expect(invoiceRepository.save).toHaveBeenCalledWith(mockInvoice); expect(notificationsService.sendInvoiceGeneratedEmail).toHaveBeenCalledWith('user1@example.com', 'invoice1'); - expect(manager.save).toHaveBeenCalledWith(CustomerSubscription, { + expect(customerSubscriptionRepository.save).toHaveBeenCalledWith({ ...mockSubscription, nextBillingDate: expect.any(Date), }); @@ -205,4 +197,4 @@ describe('BillingService', () => { ); }); }); -}); +}); \ No newline at end of file diff --git a/app/tests/services/payment.service.spec.ts b/app/tests/services/payment.service.spec.ts index 6c57e8f..149902b 100644 --- a/app/tests/services/payment.service.spec.ts +++ b/app/tests/services/payment.service.spec.ts @@ -1,7 +1,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PaymentService } from '../../src/services/payment.service'; import { getRepositoryToken } from '@nestjs/typeorm'; -import { Repository, DataSource, QueryRunner, EntityManager } from 'typeorm'; +import { Repository } from 'typeorm'; import { Payment } from '../../src/entities/payment.entity'; import { PaymentMethod } from '../../src/entities/payment-method.entity'; import { Invoice } from '../../src/entities/invoice.entity'; @@ -11,10 +11,9 @@ import { SystemSetting } from '../../src/entities/system-settings.entity'; import { StripeService } from '../../src/services/stripe.service'; import { NotificationsService } from '../../src/services/notifications.service'; import { Queue } from 'bull'; -import { NotFoundException, InternalServerErrorException } from '@nestjs/common'; +import { NotFoundException } from '@nestjs/common'; import { InvoiceStatus, JobQueues, PaymentStatus, SubscriptionStatus } from '../../src/utils/enums'; import { getQueueToken } from '@nestjs/bull'; -import Stripe from 'stripe'; describe('PaymentService', () => { let service: PaymentService; @@ -27,30 +26,8 @@ describe('PaymentService', () => { let stripeService: jest.Mocked; let notificationsService: jest.Mocked; let paymentRetryQueue: jest.Mocked; - let dataSource: jest.Mocked; - let queryRunner: jest.Mocked; - let manager: jest.Mocked; beforeEach(async () => { - manager = { - findOne: jest.fn(), - save: jest.fn(), - create: jest.fn(), - } as unknown as jest.Mocked; - - queryRunner = { - connect: jest.fn(), - startTransaction: jest.fn(), - commitTransaction: jest.fn(), - rollbackTransaction: jest.fn(), - release: jest.fn(), - manager: manager, - } as unknown as jest.Mocked; - - dataSource = { - createQueryRunner: jest.fn().mockReturnValue(queryRunner), - } as unknown as jest.Mocked; - const module: TestingModule = await Test.createTestingModule({ providers: [ PaymentService, @@ -113,10 +90,6 @@ describe('PaymentService', () => { add: jest.fn(), }, }, - { - provide: DataSource, - useValue: dataSource, - }, ], }).compile(); @@ -132,90 +105,83 @@ describe('PaymentService', () => { paymentRetryQueue = module.get(getQueueToken(JobQueues.PAYMENT_RETRY)); }); - describe('processNewPayment', () => { - it('should throw InternalServerErrorException if an error occurs', async () => { - const paymentDto = { invoiceId: 'inv_123', paymentMethodId: 'pm_123' }; - - jest.spyOn(service, 'findPendingInvoice').mockRejectedValue(new Error('Test Error')); - - await expect(service.processNewPayment(paymentDto)).rejects.toThrow(InternalServerErrorException); - }); - }); - describe('handleSuccessfulPayment', () => { it('should handle a successful payment', async () => { - const mockInvoice = { - id: 'inv_123', - amount: 1000, - subscription: { id: 'sub_123', user: { email: 'test@example.com' }, subscriptionPlan: { name: 'Test Plan' } }, - } as any; + const subscriptionId = 'sub_123'; + const paymentAmount = 1000; + const paymentMethodCode = 'card'; + + const mockInvoice = { id: 'inv_123', subscription: { id: subscriptionId, user: { email: 'test@example.com' }, subscriptionPlan: { name: 'Test Plan' } } } as any; const mockPaymentStatus = { id: 'status_123', value: PaymentStatus.VERIFIED } as any; const mockPaymentMethod = { id: 'method_123' } as any; - const mockPaidInvoiceStatus = { id: 'paid_status_123', value: InvoiceStatus.PAID } as any; - const mockPaymentIntent = { id: 'pi_123', status: 'succeeded' } as any; + const mockPendingStatus = { id: 'status_456', value: InvoiceStatus.PENDING } as any; // Mock for 'PENDING' status + const mockPaidInvoiceStatus = { id: 'paid_status_123', value: 'PAID' } as any; + + // Mocking findOne for pending status + dataLookupRepository.findOne.mockResolvedValueOnce(mockPendingStatus); - manager.findOne.mockResolvedValueOnce(mockPaymentStatus); - manager.findOne.mockResolvedValueOnce(mockPaymentMethod); - manager.create.mockReturnValue({ id: 'payment_123' } as any); - manager.findOne.mockResolvedValueOnce(mockPaidInvoiceStatus); + invoiceRepository.findOne.mockResolvedValue(mockInvoice); + dataLookupRepository.findOne.mockResolvedValueOnce(mockPaymentStatus); + paymentMethodRepository.findOne.mockResolvedValue(mockPaymentMethod); + paymentRepository.create.mockReturnValue({ id: 'payment_123' } as any); + dataLookupRepository.findOne.mockResolvedValueOnce(mockPaidInvoiceStatus); - await service.handleSuccessfulPayment(mockInvoice, mockPaymentIntent, manager); + await service.handleSuccessfulPayment(subscriptionId, paymentAmount, paymentMethodCode); - expect(manager.create).toHaveBeenCalledWith(Payment, { + expect(invoiceRepository.findOne).toHaveBeenCalledWith({ + where: { subscription: { id: subscriptionId }, status: { value: mockPendingStatus.value } }, // Use mock value here + relations: ['subscription'], + }); + expect(paymentRepository.create).toHaveBeenCalledWith({ invoice: mockInvoice, paymentMethod: mockPaymentMethod, - status: mockPaymentStatus, - amount: mockInvoice.amount, - referenceNumber: mockPaymentIntent.id, - payerName: expect.any(String), + status: mockPendingStatus, + amount: paymentAmount, paymentDate: expect.any(String), }); - expect(manager.save).toHaveBeenCalledWith(Payment, expect.any(Object)); - expect(manager.save).toHaveBeenCalledWith(Invoice, mockInvoice); - expect(notificationsService.sendPaymentSuccessEmail).toHaveBeenCalledWith( - mockInvoice.subscription.user.email, - mockInvoice.subscription.subscriptionPlan.name, - ); + expect(paymentRepository.save).toHaveBeenCalledWith(expect.any(Object)); + expect(invoiceRepository.save).toHaveBeenCalledWith(mockInvoice); + expect(notificationsService.sendPaymentSuccessEmail).toHaveBeenCalledWith('test@example.com', 'Test Plan'); }); - it('should throw NotFoundException if status is not found', async () => { - const mockInvoice = { id: 'inv_123', amount: 1000, subscription: { id: 'sub_123' } } as any; - const mockPaymentIntent = { id: 'pi_123', status: 'succeeded' } as any; + it('should throw NotFoundException if invoice is not found', async () => { + const subscriptionId = 'sub_123'; - manager.findOne.mockResolvedValue(null); + invoiceRepository.findOne.mockResolvedValue(null); - await expect(service.handleSuccessfulPayment(mockInvoice, mockPaymentIntent, manager)).rejects.toThrow(NotFoundException); + await expect(service.handleSuccessfulPayment(subscriptionId, 1000, 'card')).rejects.toThrow(NotFoundException); }); }); describe('handleFailedPayment', () => { it('should handle a failed payment', async () => { const subscriptionId = 'sub_123'; - const mockSubscription = { - id: subscriptionId, - retryCount: 0, - user: { email: 'test@example.com' }, - subscriptionPlan: { name: 'Test Plan' }, - } as CustomerSubscription; - const mockOverdueStatus = { id: 'status_123', value: SubscriptionStatus.OVERDUE } as DataLookup; - - // Mock manager.findOne calls - manager.findOne - .mockResolvedValueOnce(mockSubscription) // findSubscriptionById - .mockResolvedValueOnce(mockOverdueStatus); // findDataLookupByValue - - await service.handleFailedPayment(subscriptionId, manager); - - expect(manager.save).toHaveBeenCalledWith(CustomerSubscription, mockSubscription); + const mockSubscription = { id: subscriptionId, retryCount: 0, user: { email: 'test@example.com' }, subscriptionPlan: { name: 'Test Plan' } } as any; + const mockOverdueStatus = { id: 'status_123', value: 'OVERDUE' } as any; + const mockMaxRetriesSetting = { currentValue: '3' } as any; // Mocked max retries setting + const mockRetryDelaySetting = { currentValue: '5' } as any; // Mocked retry delay setting + + customerSubscriptionRepository.findOne.mockResolvedValue(mockSubscription); + dataLookupRepository.findOne.mockResolvedValue(mockOverdueStatus); + settingRepository.findOne.mockResolvedValueOnce(mockMaxRetriesSetting); // Mock the max retries setting + settingRepository.findOne.mockResolvedValueOnce(mockRetryDelaySetting); // Mock the retry delay setting + + await service.handleFailedPayment(subscriptionId); + + expect(customerSubscriptionRepository.findOne).toHaveBeenCalledWith({ + where: { id: subscriptionId }, + relations: ['subscriptionStatus'], + }); + expect(customerSubscriptionRepository.save).toHaveBeenCalledWith(expect.any(Object)); expect(notificationsService.sendPaymentFailureEmail).toHaveBeenCalledWith('test@example.com', 'Test Plan'); + expect(paymentRetryQueue.add).toHaveBeenCalledWith({ subscriptionId, attempt: 1 }, { delay: 5 * 60 * 1000 }); }); - it('should throw NotFoundException if subscription is not found', async () => { const subscriptionId = 'sub_123'; - manager.findOne.mockResolvedValue(null); // Mock findOne to return null for the subscription + customerSubscriptionRepository.findOne.mockResolvedValue(null); - await expect(service.handleFailedPayment(subscriptionId, manager)).rejects.toThrow(NotFoundException); + await expect(service.handleFailedPayment(subscriptionId)).rejects.toThrow(NotFoundException); }); }); @@ -226,25 +192,25 @@ describe('PaymentService', () => { const mockMaxRetriesSetting = { currentValue: '3' } as any; const mockRetryDelaySetting = { currentValue: '5' } as any; - manager.findOne.mockResolvedValueOnce(mockSubscription); - manager.findOne.mockResolvedValueOnce(mockMaxRetriesSetting); - manager.findOne.mockResolvedValueOnce(mockRetryDelaySetting); + customerSubscriptionRepository.findOne.mockResolvedValue(mockSubscription); + settingRepository.findOne.mockResolvedValueOnce(mockMaxRetriesSetting); + settingRepository.findOne.mockResolvedValueOnce(mockRetryDelaySetting); - await service.scheduleRetry(subscriptionId, 1, manager); + await service.scheduleRetry(subscriptionId); expect(paymentRetryQueue.add).toHaveBeenCalledWith( { subscriptionId, attempt: 1 }, { delay: 5 * 60 * 1000 }, ); - expect(manager.save).toHaveBeenCalledWith(CustomerSubscription, expect.any(Object)); + expect(customerSubscriptionRepository.save).toHaveBeenCalledWith(expect.any(Object)); }); it('should throw NotFoundException if subscription is not found', async () => { const subscriptionId = 'sub_123'; - manager.findOne.mockResolvedValue(null); + customerSubscriptionRepository.findOne.mockResolvedValue(null); - await expect(service.scheduleRetry(subscriptionId, 1, manager)).rejects.toThrow(NotFoundException); + await expect(service.scheduleRetry(subscriptionId)).rejects.toThrow(NotFoundException); }); it('should not schedule a retry if retry count exceeds max retries', async () => { @@ -252,12 +218,12 @@ describe('PaymentService', () => { const mockSubscription = { id: subscriptionId, retryCount: 3 } as any; const mockMaxRetriesSetting = { currentValue: '3' } as any; - manager.findOne.mockResolvedValueOnce(mockSubscription); - manager.findOne.mockResolvedValueOnce(mockMaxRetriesSetting); + customerSubscriptionRepository.findOne.mockResolvedValue(mockSubscription); + settingRepository.findOne.mockResolvedValueOnce(mockMaxRetriesSetting); const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(); - await service.scheduleRetry(subscriptionId, 1, manager); + await service.scheduleRetry(subscriptionId); expect(consoleLogSpy).toHaveBeenCalledWith(`Subscription ID ${subscriptionId} has reached the maximum number of retries.`); expect(paymentRetryQueue.add).not.toHaveBeenCalled(); @@ -265,81 +231,140 @@ describe('PaymentService', () => { }); describe('retryPayment', () => { + it('should retry a payment and succeed', async () => { + const subscriptionId = 'sub_123'; + const mockInvoice = { id: 'inv_123', amount: 100, status: { value: 'FAILED' } } as any; + const mockPaymentIntent = { id: 'pi_123', status: 'succeeded' } as any; + const mockPaidStatus = { id: 'paid_status_123', value: 'PAID' } as any; + const mockPaymentMethod = { id: 'method_123' } as any; + + invoiceRepository.findOne.mockResolvedValue(mockInvoice); + stripeService.createPaymentIntent.mockResolvedValue(mockPaymentIntent); + dataLookupRepository.findOne.mockResolvedValue(mockPaidStatus); + paymentMethodRepository.findOne.mockResolvedValue(mockPaymentMethod); + paymentRepository.create.mockReturnValue({ id: 'payment_123' } as any); + + const result = await service.retryPayment(subscriptionId); + + expect(result.success).toBe(true); + expect(invoiceRepository.save).toHaveBeenCalledWith(mockInvoice); + expect(paymentRepository.create).toHaveBeenCalledWith({ + amount: mockInvoice.amount, + status: mockPaidStatus, + invoice: mockInvoice, + paymentMethod: mockPaymentMethod, + referenceNumber: mockPaymentIntent.id, + payerName: expect.any(String), + paymentDate: expect.any(String), + }); + expect(paymentRepository.save).toHaveBeenCalledWith(expect.any(Object)); + }); + it('should throw NotFoundException if no unpaid invoice is found', async () => { const subscriptionId = 'sub_123'; - manager.findOne.mockResolvedValue(null); // Mock findOne to return null + invoiceRepository.findOne.mockResolvedValue(null); - await expect(service.retryPayment(subscriptionId, manager)).rejects.toThrow(NotFoundException); + await expect(service.retryPayment(subscriptionId)).rejects.toThrow(NotFoundException); }); it('should return false if payment fails', async () => { const subscriptionId = 'sub_123'; - const mockInvoice = { id: 'inv_123', amount: 100, subscription: { id: subscriptionId } } as any; + const mockInvoice = { id: 'inv_123', amount: 100, status: { value: 'FAILED' } } as any; const mockPaymentIntent = { id: 'pi_123', status: 'failed' } as any; - manager.findOne.mockResolvedValue(mockInvoice); + invoiceRepository.findOne.mockResolvedValue(mockInvoice); stripeService.createPaymentIntent.mockResolvedValue(mockPaymentIntent); - const result = await service.retryPayment(subscriptionId, manager); + const result = await service.retryPayment(subscriptionId); expect(result.success).toBe(false); }); it('should log an error and return success: false if an error occurs', async () => { const subscriptionId = 'sub_123'; - const mockInvoice = { id: 'inv_123', amount: 100, subscription: { id: subscriptionId } } as Invoice; + const mockInvoice = { id: 'inv_123', amount: 100, status: { value: InvoiceStatus.FAILED } } as any; const error = new Error('Test error'); - manager.findOne.mockResolvedValue(mockInvoice); // Mock finding the invoice - stripeService.createPaymentIntent.mockRejectedValue(error); // Mock an error when creating a payment intent + invoiceRepository.findOne.mockResolvedValue(mockInvoice); + stripeService.createPaymentIntent.mockRejectedValue(error); const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); - const result = await service.retryPayment(subscriptionId, manager); + const result = await service.retryPayment(subscriptionId); expect(consoleErrorSpy).toHaveBeenCalledWith(`Failed to process payment for invoice ID ${mockInvoice.id}:`, error); expect(result.success).toBe(false); }); }); + describe('getCustomerInfo', () => { + it('should return the customer name if available', () => { + const mockCustomer = { name: 'Test Customer' } as any; + + const result = service.getCustomerInfo(mockCustomer); + + expect(result).toBe('Test Customer'); + }); + + it('should return "Stripe Customer" if customer name is not available', () => { + const mockCustomer = {} as any; + + const result = service.getCustomerInfo(mockCustomer); + + expect(result).toBe('Stripe Customer'); + }); + + it('should return customer string if input is a string', () => { + const result = service.getCustomerInfo('cus_123'); + + expect(result).toBe('cus_123'); + }); + + it('should return "Stripe Customer" if customer is null', () => { + const result = service.getCustomerInfo(null); + + expect(result).toBe('Stripe Customer'); + }); + }); + describe('confirmPayment', () => { it('should confirm payment and update subscription status', async () => { const subscriptionId = 'sub_123'; - const mockActiveStatus = { id: 'status_123', value: SubscriptionStatus.ACTIVE } as any; const mockSubscription = { id: subscriptionId, subscriptionStatus: {} } as any; + const mockActiveStatus = { id: 'status_123', value: 'ACTIVE' } as any; - manager.findOne.mockResolvedValueOnce(mockSubscription); // Mock subscription retrieval - manager.findOne.mockResolvedValueOnce(mockActiveStatus); // Mock status retrieval + customerSubscriptionRepository.findOne.mockResolvedValue(mockSubscription); + dataLookupRepository.findOne.mockResolvedValue(mockActiveStatus); - await service.confirmPayment(subscriptionId, manager); + await service.confirmPayment(subscriptionId); - expect(manager.findOne).toHaveBeenCalledWith(CustomerSubscription, { + expect(customerSubscriptionRepository.findOne).toHaveBeenCalledWith({ where: { id: subscriptionId }, relations: ['subscriptionStatus'], }); - expect(manager.findOne).toHaveBeenCalledWith(DataLookup, { - where: { value: SubscriptionStatus.ACTIVE }, + expect(dataLookupRepository.findOne).toHaveBeenCalledWith({ + where: { type: SubscriptionStatus.TYPE, value: SubscriptionStatus.ACTIVE }, }); - expect(manager.save).toHaveBeenCalledWith(CustomerSubscription, { ...mockSubscription, subscriptionStatus: mockActiveStatus }); + expect(customerSubscriptionRepository.save).toHaveBeenCalledWith(mockSubscription); }); it('should throw NotFoundException if subscription is not found', async () => { const subscriptionId = 'sub_123'; - manager.findOne.mockResolvedValue(null); + customerSubscriptionRepository.findOne.mockResolvedValue(null); - await expect(service.confirmPayment(subscriptionId, manager)).rejects.toThrow(NotFoundException); + await expect(service.confirmPayment(subscriptionId)).rejects.toThrow(NotFoundException); }); it('should throw NotFoundException if active status is not found', async () => { const subscriptionId = 'sub_123'; const mockSubscription = { id: subscriptionId, subscriptionStatus: {} } as any; - manager.findOne.mockResolvedValue(mockSubscription); - manager.findOne.mockResolvedValue(null); + customerSubscriptionRepository.findOne.mockResolvedValue(mockSubscription); + dataLookupRepository.findOne.mockResolvedValue(null); - await expect(service.confirmPayment(subscriptionId, manager)).rejects.toThrow(NotFoundException); + await expect(service.confirmPayment(subscriptionId)).rejects.toThrow(NotFoundException); }); }); }); diff --git a/app/tests/services/subscription.service.spec.ts b/app/tests/services/subscription.service.spec.ts index c06d5bc..497ea7f 100644 --- a/app/tests/services/subscription.service.spec.ts +++ b/app/tests/services/subscription.service.spec.ts @@ -8,8 +8,6 @@ import { SubscriptionPlan } from '../../src/entities/subscription.entity'; import { DataLookup } from '../../src/entities/data-lookup.entity'; import { NotFoundException } from '@nestjs/common'; import { CreateSubscriptionDto, CreateSubscriptionPlanDto, UpdateSubscriptionPlanDto, UpdateSubscriptionStatusDto } from '../../src/dtos/subscription.dto'; -import { BillingService } from '../../src/services/billing.service'; -import { PaymentService } from '../../src/services/payment.service'; jest.mock('../../src/services/base.service'); @@ -19,22 +17,9 @@ describe('SubscriptionService', () => { let userRepository: jest.Mocked>; let subscriptionPlanRepository: jest.Mocked>; let dataLookupRepository: jest.Mocked>; - let billingService: jest.Mocked; let dataSource: DataSource; beforeEach(async () => { - const mockQueryRunner = { - connect: jest.fn(), - startTransaction: jest.fn(), - commitTransaction: jest.fn(), - rollbackTransaction: jest.fn(), - release: jest.fn(), - manager: { - findOne: jest.fn(), - save: jest.fn(), - }, - }; - const module: TestingModule = await Test.createTestingModule({ providers: [ SubscriptionService, @@ -71,27 +56,13 @@ describe('SubscriptionService', () => { findOne: jest.fn(), }, }, - { - provide: BillingService, - useValue: { - createInvoiceForSubscription: jest.fn(), - }, - }, - { - provide: PaymentService, - useValue: { - processPayment: jest.fn(), - }, - }, { provide: DataSource, - useValue: { - createQueryRunner: jest.fn().mockReturnValue(mockQueryRunner), - }, + useValue: {}, }, ], }).compile(); - + service = module.get(SubscriptionService); customerSubscriptionRepository = module.get(getRepositoryToken(CustomerSubscription)); userRepository = module.get(getRepositoryToken(User)); @@ -99,7 +70,6 @@ describe('SubscriptionService', () => { dataLookupRepository = module.get(getRepositoryToken(DataLookup)); dataSource = module.get(DataSource); }); - describe('createCustomerSubscription', () => { it('should create a new customer subscription', async () => { diff --git a/app/tsconfig.json b/app/tsconfig.json index 3748d1c..8c4d17b 100644 --- a/app/tsconfig.json +++ b/app/tsconfig.json @@ -22,6 +22,6 @@ "forceConsistentCasingInFileNames": false, "noFallthroughCasesInSwitch": false, }, - "include": ["src/**/*", "tests/**/*", "db/migrations/**/*.ts"], + "include": ["src/**/*", "tests/**/*"], "exclude": ["node_modules"] -} \ No newline at end of file +}