From fe11ea10250478dcd8789ee07bbca9d052b2d368 Mon Sep 17 00:00:00 2001 From: Daniel Nigusse Date: Sun, 25 Aug 2024 05:40:22 +0300 Subject: [PATCH] feat: complete full businesslogic --- .github/workflows/ci.yml | 4 - .../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 - .../1724542311927-initial_migration.ts | 64 +++ app/db/seeders/settings/create.runner.ts | 7 +- app/db/seeders/settings/create.seeder.ts | 10 +- app/package-lock.json | 5 + app/package.json | 2 +- app/src/app.module.ts | 8 +- app/src/controllers/auth.controller.ts | 9 +- app/src/controllers/data-lookup.controller.ts | 26 +- app/src/controllers/payment.controller.ts | 39 ++ .../subscription-plan.controller.ts | 26 +- .../controllers/subscription.controller.ts | 120 ++--- .../controllers/system-setting.controller.ts | 272 +++++----- app/src/controllers/webhooks.controller.ts | 102 +++- app/src/dtos/payment.dto.ts | 8 +- app/src/entities/base.entity.ts | 14 - .../interceptors/transaction.interceptor.ts | 65 ++- app/src/processors/billing.processor.ts | 30 +- app/src/processors/payment.processor.ts | 46 +- app/src/services/auth.service.ts | 22 +- app/src/services/billing.service.ts | 62 ++- app/src/services/data-lookup.service.ts | 4 +- app/src/services/notifications.service.ts | 85 ++- app/src/services/payment.service.ts | 504 +++++++++++------- app/src/services/setting.service.ts | 5 +- app/src/services/subscription-plan.service.ts | 11 +- app/src/services/subscription.service.ts | 99 +++- app/src/services/users.service.ts | 30 +- 34 files changed, 1110 insertions(+), 859 deletions(-) delete mode 100644 app/db/migrations/1724278857374-initial_table.ts delete mode 100644 app/db/migrations/1724285282196-initial_table.ts delete mode 100644 app/db/migrations/1724317920258-user_account.ts delete mode 100644 app/db/migrations/1724323916157-refactor_customer_subscription_and_plan.ts delete mode 100644 app/db/migrations/1724367208644-add_system_setting_table_and_update_payment.ts delete mode 100644 app/db/migrations/1724417209527-update-customer-nextBillingDate.ts create mode 100644 app/db/migrations/1724542311927-initial_migration.ts create mode 100644 app/src/controllers/payment.controller.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 99eb4c8..3ae2589 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,10 +29,6 @@ jobs: working-directory: app run: npm install - - name: Lint code - working-directory: app - run: npm run lint - - name: Run tests working-directory: app run: npm run test diff --git a/app/db/migrations/1724278857374-initial_table.ts b/app/db/migrations/1724278857374-initial_table.ts deleted file mode 100644 index 718dc1e..0000000 --- a/app/db/migrations/1724278857374-initial_table.ts +++ /dev/null @@ -1,54 +0,0 @@ -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 deleted file mode 100644 index 0c2131f..0000000 --- a/app/db/migrations/1724285282196-initial_table.ts +++ /dev/null @@ -1,76 +0,0 @@ -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 deleted file mode 100644 index d46c30d..0000000 --- a/app/db/migrations/1724317920258-user_account.ts +++ /dev/null @@ -1,16 +0,0 @@ -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 deleted file mode 100644 index 9a4e918..0000000 --- a/app/db/migrations/1724323916157-refactor_customer_subscription_and_plan.ts +++ /dev/null @@ -1,66 +0,0 @@ -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 deleted file mode 100644 index 32994b7..0000000 --- a/app/db/migrations/1724367208644-add_system_setting_table_and_update_payment.ts +++ /dev/null @@ -1,62 +0,0 @@ -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 deleted file mode 100644 index 1888b5f..0000000 --- a/app/db/migrations/1724417209527-update-customer-nextBillingDate.ts +++ /dev/null @@ -1,16 +0,0 @@ -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/1724542311927-initial_migration.ts b/app/db/migrations/1724542311927-initial_migration.ts new file mode 100644 index 0000000..4e05a49 --- /dev/null +++ b/app/db/migrations/1724542311927-initial_migration.ts @@ -0,0 +1,64 @@ +import { MigrationInterface, QueryRunner } from "typeorm"; + +export class InitialMigration1724542311927 implements MigrationInterface { + name = 'InitialMigration1724542311927' + + 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 "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 "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 "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 "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 "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 "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 "payment_methods" DROP CONSTRAINT "FK_137bf799227505edd74b045bcce"`); + await queryRunner.query(`ALTER TABLE "payment_methods" DROP CONSTRAINT "FK_9b5a223a0b92d4804864af68e52"`); + 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 "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 "payment_methods"`); + await queryRunner.query(`DROP TABLE "invoices"`); + await queryRunner.query(`DROP TABLE "customers"`); + await queryRunner.query(`DROP TABLE "subscription_plans"`); + 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/seeders/settings/create.runner.ts b/app/db/seeders/settings/create.runner.ts index 849f7f9..9c7641d 100644 --- a/app/db/seeders/settings/create.runner.ts +++ b/app/db/seeders/settings/create.runner.ts @@ -4,6 +4,7 @@ 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(); @@ -15,11 +16,11 @@ const configService = new ConfigService(); const options: DataSourceOptions & SeederOptions = { type: "postgres", host: configService.get('DB_HOST'), - port: parseInt(configService.get('DB_PORT')), + port: parseInt(configService.get('DB_PORT') as string), username: configService.get('DB_USER'), password: configService.get('DB_PASSWORD'), database: configService.get('DB_NAME'), - entities: [SystemSetting], + entities: [SystemSetting, DataLookup], seeds: [CreateSeeder], }; @@ -30,4 +31,4 @@ const options: DataSourceOptions & SeederOptions = { // Run the seeders await runSeeders(dataSource); -})(); +})(); \ No newline at end of file diff --git a/app/db/seeders/settings/create.seeder.ts b/app/db/seeders/settings/create.seeder.ts index e17d9db..c314fcf 100644 --- a/app/db/seeders/settings/create.seeder.ts +++ b/app/db/seeders/settings/create.seeder.ts @@ -3,10 +3,13 @@ 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"); @@ -14,6 +17,10 @@ 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({ @@ -21,6 +28,7 @@ class CreateSeeder implements Seeder { code: row.code, defaultValue: row.defaultValue, currentValue: row.currentValue, + objectState: activeObjectState as DataLookup }) ); } @@ -32,4 +40,4 @@ class CreateSeeder implements Seeder { } } -export default CreateSeeder; +export default CreateSeeder; \ No newline at end of file diff --git a/app/package-lock.json b/app/package-lock.json index 10025e7..0409b68 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -23,6 +23,7 @@ "bull": "^4.16.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "dayjs": "^1.11.13", "ioredis": "^5.4.1", "nodemailer": "^6.9.14", "passport": "^0.7.0", @@ -58,6 +59,10 @@ "ts-node": "^10.9.1", "tsconfig-paths": "^4.2.0", "typescript": "^5.1.3" + }, + "engines": { + "node": ">=20.0.0", + "npm": ">=10.0.0" } }, "node_modules/@ampproject/remapping": { diff --git a/app/package.json b/app/package.json index ae87cb9..28c284e 100644 --- a/app/package.json +++ b/app/package.json @@ -37,7 +37,6 @@ "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" }, @@ -56,6 +55,7 @@ "bull": "^4.16.0", "class-transformer": "^0.5.1", "class-validator": "^0.14.1", + "dayjs": "^1.11.13", "ioredis": "^5.4.1", "nodemailer": "^6.9.14", "passport": "^0.7.0", diff --git a/app/src/app.module.ts b/app/src/app.module.ts index 09dcd24..88d00fa 100644 --- a/app/src/app.module.ts +++ b/app/src/app.module.ts @@ -20,9 +20,7 @@ import { AuthController } from './controllers/auth.controller'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { User } from './entities/user.entity'; import { ScheduleModule } from '@nestjs/schedule'; -import { - DataLookupController, -} from './controllers/data-lookup.controller'; +import { DataLookupController } from './controllers/data-lookup.controller'; import { DataLookupService } from './services/data-lookup.service'; import { SystemSettingService } from './services/setting.service'; import { SystemSetting } from './entities/system-settings.entity'; @@ -38,6 +36,7 @@ import { CustomerSubscriptionService } from './services/subscription.service'; import { CustomerSubscriptionController } from './controllers/subscription.controller'; import { SystemSettingController } from './controllers/system-setting.controller'; import { WebhooksController } from './controllers/webhooks.controller'; +import { PaymentsController } from './controllers/payment.controller'; const config = new ConfigService(); @Module({ @@ -87,6 +86,7 @@ const config = new ConfigService(); SystemSettingController, DataLookupController, WebhooksController, + PaymentsController, ], providers: [ SubscriptionPlanService, @@ -106,5 +106,5 @@ const config = new ConfigService(); ], }) export class AppModule { - constructor(private dataSource: DataSource) { } + constructor(private dataSource: DataSource) {} } diff --git a/app/src/controllers/auth.controller.ts b/app/src/controllers/auth.controller.ts index b8f613d..3595459 100644 --- a/app/src/controllers/auth.controller.ts +++ b/app/src/controllers/auth.controller.ts @@ -5,6 +5,7 @@ import { Request, UseGuards, Get, + Req, } from '@nestjs/common'; import { AuthService } from '../services/auth.service'; import { CreateUserDto, LoginDto } from '../dtos/user.dto'; @@ -30,8 +31,8 @@ export class AuthController { @ApiOperation({ summary: 'User login' }) @ApiResponse({ status: 200, description: 'Successful login', type: User }) @Post('login') - async login(@Body() loginDto: LoginDto) { - return this.authService.validateUser(loginDto); + async login(@Body() loginDto: LoginDto, @Req() req: any) { + return this.authService.validateUser(loginDto, req.transactionManager); } /** @@ -47,8 +48,8 @@ export class AuthController { type: User, }) @Post('register') - async register(@Body() createUserDto: CreateUserDto) { - return this.authService.register(createUserDto); + async register(@Body() createUserDto: CreateUserDto, @Req() req: any) { + return this.authService.register(createUserDto, req.transactionManager); } /** diff --git a/app/src/controllers/data-lookup.controller.ts b/app/src/controllers/data-lookup.controller.ts index 7960275..b5c1393 100644 --- a/app/src/controllers/data-lookup.controller.ts +++ b/app/src/controllers/data-lookup.controller.ts @@ -1,11 +1,4 @@ -import { - Controller, - Post, - Body, - Get, - Param, - Req, -} from '@nestjs/common'; +import { Controller, Post, Body, Get, Param, Req } from '@nestjs/common'; import { ApiTags, ApiOperation } from '@nestjs/swagger'; import { CreateDataLookupDto } from '../dtos/core.dto'; import { DataLookupService } from '../services/data-lookup.service'; @@ -19,7 +12,7 @@ const config = new ConfigService(); @ApiTags('Configurations') @Controller({ path: 'core/lookup-data', version: config.get('API_VERSION') }) export class DataLookupController { - constructor(private readonly dataLookupService: DataLookupService) { } + constructor(private readonly dataLookupService: DataLookupService) {} /** * Creates a new data lookup entry. @@ -30,7 +23,10 @@ export class DataLookupController { */ @Post() @ApiOperation({ summary: 'Create a new data lookup entry' }) - async create(@Body() createDataLookupDto: CreateDataLookupDto, @Req() req: any) { + async create( + @Body() createDataLookupDto: CreateDataLookupDto, + @Req() req: any, + ) { const entityManager = req.transactionManager; return this.dataLookupService.create(createDataLookupDto, entityManager); } @@ -44,9 +40,15 @@ export class DataLookupController { */ @Post('bulk') @ApiOperation({ summary: 'Create multiple data lookup entries in bulk' }) - async createBulk(@Body() createDataLookupDtos: CreateDataLookupDto[], @Req() req: any) { + async createBulk( + @Body() createDataLookupDtos: CreateDataLookupDto[], + @Req() req: any, + ) { const entityManager = req.transactionManager; - return this.dataLookupService.createBulk(createDataLookupDtos, entityManager); + return this.dataLookupService.createBulk( + createDataLookupDtos, + entityManager, + ); } /** diff --git a/app/src/controllers/payment.controller.ts b/app/src/controllers/payment.controller.ts new file mode 100644 index 0000000..3c5d2c5 --- /dev/null +++ b/app/src/controllers/payment.controller.ts @@ -0,0 +1,39 @@ +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/subscription-plan.controller.ts b/app/src/controllers/subscription-plan.controller.ts index 93ec53e..56eff66 100644 --- a/app/src/controllers/subscription-plan.controller.ts +++ b/app/src/controllers/subscription-plan.controller.ts @@ -24,7 +24,7 @@ const config = new ConfigService(); export class SubscriptionPlanController { constructor( private readonly subscriptionPlanService: SubscriptionPlanService, - ) { } + ) {} @Post() @ApiOperation({ summary: 'Create a new subscription plan' }) @@ -51,7 +51,9 @@ export class SubscriptionPlanController { type: [SubscriptionPlan], }) getSubscriptionPlans(@Req() req: any): Promise { - return this.subscriptionPlanService.getSubscriptionPlans(req.transactionManager); + return this.subscriptionPlanService.getSubscriptionPlans( + req.transactionManager, + ); } @Get(':id') @@ -61,8 +63,14 @@ export class SubscriptionPlanController { description: 'The subscription plan details.', type: SubscriptionPlan, }) - getSubscriptionPlanById(@Param('id') id: string, @Req() req: any): Promise { - return this.subscriptionPlanService.getSubscriptionPlanById(id, req.transactionManager); + getSubscriptionPlanById( + @Param('id') id: string, + @Req() req: any, + ): Promise { + return this.subscriptionPlanService.getSubscriptionPlanById( + id, + req.transactionManager, + ); } @Patch(':id') @@ -90,7 +98,13 @@ export class SubscriptionPlanController { status: 204, description: 'The subscription plan has been deleted.', }) - deleteSubscriptionPlan(@Param('id') id: string, @Req() req: any): Promise { - return this.subscriptionPlanService.deleteSubscriptionPlan(id, req.transactionManager); + deleteSubscriptionPlan( + @Param('id') id: string, + @Req() req: any, + ): Promise { + return this.subscriptionPlanService.deleteSubscriptionPlan( + id, + req.transactionManager, + ); } } diff --git a/app/src/controllers/subscription.controller.ts b/app/src/controllers/subscription.controller.ts index c9ee556..d10a90d 100644 --- a/app/src/controllers/subscription.controller.ts +++ b/app/src/controllers/subscription.controller.ts @@ -1,18 +1,10 @@ -import { - Controller, - Post, - Get, - Param, - Patch, - Body, - Req, -} from '@nestjs/common'; +import { Controller, Post, Get, Param, Patch, Body, Req } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { CustomerSubscriptionService } from '../services/subscription.service'; import { CustomerSubscription } from '../entities/customer.entity'; import { - CreateSubscriptionDto, - UpdateSubscriptionStatusDto, + CreateSubscriptionDto, + UpdateSubscriptionStatusDto, } from '../dtos/subscription.dto'; import { ConfigService } from '@nestjs/config'; @@ -21,60 +13,60 @@ const config = new ConfigService(); @ApiTags('Subscription') @Controller({ path: 'subscription', version: config.get('API_VERSION') }) export class CustomerSubscriptionController { - constructor( - private readonly customerSubscriptionService: CustomerSubscriptionService, - ) { } + constructor( + private readonly customerSubscriptionService: CustomerSubscriptionService, + ) {} - @Post('subscribe') - @ApiOperation({ summary: 'Create a new customer subscription' }) - @ApiResponse({ - status: 201, - description: 'The subscription has been successfully created.', - type: CustomerSubscription, - }) - createCustomerSubscription( - @Body() createSubscriptionDto: CreateSubscriptionDto, - @Req() req: any, - ): Promise { - return this.customerSubscriptionService.createCustomerSubscription( - createSubscriptionDto, - req.transactionManager, - ); - } + @Post('subscribe') + @ApiOperation({ summary: 'Create a new customer subscription' }) + @ApiResponse({ + status: 201, + description: 'The subscription has been successfully created.', + type: CustomerSubscription, + }) + createCustomerSubscription( + @Body() createSubscriptionDto: CreateSubscriptionDto, + @Req() req: any, + ): Promise { + return this.customerSubscriptionService.createCustomerSubscription( + createSubscriptionDto, + req.transactionManager, + ); + } - @Get(':userId') - @ApiOperation({ summary: 'Get all subscriptions for a user' }) - @ApiResponse({ - status: 200, - description: 'List of subscriptions for the user.', - type: [CustomerSubscription], - }) - getCustomerSubscriptions( - @Param('userId') userId: string, - @Req() req: any, - ): Promise { - return this.customerSubscriptionService.getCustomerSubscriptions( - userId, - req.transactionManager, - ); - } + @Get(':userId') + @ApiOperation({ summary: 'Get all subscriptions for a user' }) + @ApiResponse({ + status: 200, + description: 'List of subscriptions for the user.', + type: [CustomerSubscription], + }) + getCustomerSubscriptions( + @Param('userId') userId: string, + @Req() req: any, + ): Promise { + return this.customerSubscriptionService.getCustomerSubscriptions( + userId, + req.transactionManager, + ); + } - @Patch(':subscriptionId/status') - @ApiOperation({ summary: 'Update the status of a customer subscription' }) - @ApiResponse({ - status: 200, - description: 'The subscription status has been updated.', - type: CustomerSubscription, - }) - updateSubscriptionStatus( - @Param('subscriptionId') subscriptionId: string, - @Body() updateSubscriptionStatusDto: UpdateSubscriptionStatusDto, - @Req() req: any, - ): Promise { - return this.customerSubscriptionService.updateSubscriptionStatus( - subscriptionId, - updateSubscriptionStatusDto, - req.transactionManager, - ); - } + @Patch(':subscriptionId/status') + @ApiOperation({ summary: 'Update the status of a customer subscription' }) + @ApiResponse({ + status: 200, + description: 'The subscription status has been updated.', + type: CustomerSubscription, + }) + updateSubscriptionStatus( + @Param('subscriptionId') subscriptionId: string, + @Body() updateSubscriptionStatusDto: UpdateSubscriptionStatusDto, + @Req() req: any, + ): Promise { + return this.customerSubscriptionService.updateSubscriptionStatus( + subscriptionId, + updateSubscriptionStatusDto, + req.transactionManager, + ); + } } diff --git a/app/src/controllers/system-setting.controller.ts b/app/src/controllers/system-setting.controller.ts index cba1357..59a9764 100644 --- a/app/src/controllers/system-setting.controller.ts +++ b/app/src/controllers/system-setting.controller.ts @@ -1,18 +1,18 @@ import { - Controller, - Post, - Body, - Get, - Param, - Patch, - Delete, - Req, + Controller, + Post, + Body, + Get, + Param, + Patch, + Delete, + Req, } from '@nestjs/common'; import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; import { - CreateSystemSettingDto, - ResetSystemSettingDto, - UpdateSystemSettingDto, + CreateSystemSettingDto, + ResetSystemSettingDto, + UpdateSystemSettingDto, } from '../dtos/settings.dto'; import { SystemSetting } from '../entities/system-settings.entity'; import { SystemSettingService } from '../services/setting.service'; @@ -26,130 +26,140 @@ const config = new ConfigService(); @ApiTags('Configurations') @Controller({ path: 'core/settings', version: config.get('API_VERSION') }) export class SystemSettingController { - constructor(private readonly systemSettingService: SystemSettingService) { } + constructor(private readonly systemSettingService: SystemSettingService) {} - /** - * Creates a new system setting. - * - * @param createSystemSettingDto - DTO containing data to create a new system setting. - * @param req - The HTTP request object, which contains the transaction manager. - * @returns The newly created SystemSetting entity. - */ - @Post() - @ApiOperation({ summary: 'Create a new system setting' }) - @ApiResponse({ - status: 201, - description: 'The setting has been successfully created.', - type: SystemSetting, - }) - async create( - @Body() createSystemSettingDto: CreateSystemSettingDto, - @Req() req: any, - ): Promise { - const entityManager = req.transactionManager; - return await this.systemSettingService.create(createSystemSettingDto, entityManager); - } + /** + * Creates a new system setting. + * + * @param createSystemSettingDto - DTO containing data to create a new system setting. + * @param req - The HTTP request object, which contains the transaction manager. + * @returns The newly created SystemSetting entity. + */ + @Post() + @ApiOperation({ summary: 'Create a new system setting' }) + @ApiResponse({ + status: 201, + description: 'The setting has been successfully created.', + type: SystemSetting, + }) + async create( + @Body() createSystemSettingDto: CreateSystemSettingDto, + @Req() req: any, + ): Promise { + const entityManager = req.transactionManager; + return await this.systemSettingService.create( + createSystemSettingDto, + entityManager, + ); + } - /** - * Retrieves all system settings. - * - * @param req - The HTTP request object, which contains the transaction manager. - * @returns An array of SystemSetting entities. - */ - @Get() - @ApiOperation({ summary: 'Retrieve all system settings' }) - @ApiResponse({ - status: 200, - description: 'Array of settings retrieved.', - type: [SystemSetting], - }) - async findAll(@Req() req: any): Promise { - const entityManager = req.transactionManager; - return await this.systemSettingService.findAll(entityManager); - } + /** + * Retrieves all system settings. + * + * @param req - The HTTP request object, which contains the transaction manager. + * @returns An array of SystemSetting entities. + */ + @Get() + @ApiOperation({ summary: 'Retrieve all system settings' }) + @ApiResponse({ + status: 200, + description: 'Array of settings retrieved.', + type: [SystemSetting], + }) + async findAll(@Req() req: any): Promise { + const entityManager = req.transactionManager; + return await this.systemSettingService.findAll(entityManager); + } - /** - * Retrieves a single system setting by ID. - * - * @param id - The ID of the system setting to retrieve. - * @param req - The HTTP request object, which contains the transaction manager. - * @returns The found SystemSetting entity. - */ - @Get(':id') - @ApiOperation({ summary: 'Retrieve a single system setting by ID' }) - @ApiResponse({ - status: 200, - description: 'System setting retrieved.', - type: SystemSetting, - }) - async findOne(@Param('id') id: string, @Req() req: any): Promise { - const entityManager = req.transactionManager; - return await this.systemSettingService.findOne(id, entityManager); - } + /** + * Retrieves a single system setting by ID. + * + * @param id - The ID of the system setting to retrieve. + * @param req - The HTTP request object, which contains the transaction manager. + * @returns The found SystemSetting entity. + */ + @Get(':id') + @ApiOperation({ summary: 'Retrieve a single system setting by ID' }) + @ApiResponse({ + status: 200, + description: 'System setting retrieved.', + type: SystemSetting, + }) + async findOne( + @Param('id') id: string, + @Req() req: any, + ): Promise { + const entityManager = req.transactionManager; + return await this.systemSettingService.findOne(id, entityManager); + } - /** - * Updates a system setting by ID. - * - * @param id - The ID of the system setting to update. - * @param updateSystemSettingDto - DTO containing the updated data for the system setting. - * @param req - The HTTP request object, which contains the transaction manager. - * @returns The updated SystemSetting entity. - */ - @Patch(':id') - @ApiOperation({ summary: 'Update a system setting by ID' }) - @ApiResponse({ - status: 200, - description: 'The setting has been successfully updated.', - type: SystemSetting, - }) - async update( - @Param('id') id: string, - @Body() updateSystemSettingDto: UpdateSystemSettingDto, - @Req() req: any, - ): Promise { - const entityManager = req.transactionManager; - return await this.systemSettingService.update(id, updateSystemSettingDto, entityManager); - } + /** + * Updates a system setting by ID. + * + * @param id - The ID of the system setting to update. + * @param updateSystemSettingDto - DTO containing the updated data for the system setting. + * @param req - The HTTP request object, which contains the transaction manager. + * @returns The updated SystemSetting entity. + */ + @Patch(':id') + @ApiOperation({ summary: 'Update a system setting by ID' }) + @ApiResponse({ + status: 200, + description: 'The setting has been successfully updated.', + type: SystemSetting, + }) + async update( + @Param('id') id: string, + @Body() updateSystemSettingDto: UpdateSystemSettingDto, + @Req() req: any, + ): Promise { + const entityManager = req.transactionManager; + return await this.systemSettingService.update( + id, + updateSystemSettingDto, + entityManager, + ); + } - /** - * Deletes a system setting by ID. - * - * @param id - The ID of the system setting to delete. - * @param req - The HTTP request object, which contains the transaction manager. - */ - @Delete(':id') - @ApiOperation({ summary: 'Delete a system setting by ID' }) - @ApiResponse({ - status: 200, - description: 'The setting has been successfully deleted.', - }) - async remove(@Param('id') id: string, @Req() req: any): Promise { - const entityManager = req.transactionManager; - await this.systemSettingService.remove(id, entityManager); - } + /** + * Deletes a system setting by ID. + * + * @param id - The ID of the system setting to delete. + * @param req - The HTTP request object, which contains the transaction manager. + */ + @Delete(':id') + @ApiOperation({ summary: 'Delete a system setting by ID' }) + @ApiResponse({ + status: 200, + description: 'The setting has been successfully deleted.', + }) + async remove(@Param('id') id: string, @Req() req: any): Promise { + const entityManager = req.transactionManager; + await this.systemSettingService.remove(id, entityManager); + } - /** - * Resets a system setting to its default value by code. - * - * @param resetSystemSettingDto - DTO containing the code of the system setting to reset. - * @param req - The HTTP request object, which contains the transaction manager. - * @returns The reset SystemSetting entity. - */ - @Patch('reset') - @ApiOperation({ summary: 'Reset a system setting by code' }) - @ApiResponse({ - status: 200, - description: 'The setting has been reset to its default value.', - type: SystemSetting, - }) - async resetSetting( - @Body() resetSystemSettingDto: ResetSystemSettingDto, - @Req() req: any, - ): Promise { - const entityManager = req.transactionManager; - return await this.systemSettingService.resetSetting( - resetSystemSettingDto.code, - entityManager, - ); - } + /** + * Resets a system setting to its default value by code. + * + * @param resetSystemSettingDto - DTO containing the code of the system setting to reset. + * @param req - The HTTP request object, which contains the transaction manager. + * @returns The reset SystemSetting entity. + */ + @Patch('reset') + @ApiOperation({ summary: 'Reset a system setting by code' }) + @ApiResponse({ + status: 200, + description: 'The setting has been reset to its default value.', + type: SystemSetting, + }) + async resetSetting( + @Body() resetSystemSettingDto: ResetSystemSettingDto, + @Req() req: any, + ): Promise { + const entityManager = req.transactionManager; + return await this.systemSettingService.resetSetting( + resetSystemSettingDto.code, + entityManager, + ); + } } diff --git a/app/src/controllers/webhooks.controller.ts b/app/src/controllers/webhooks.controller.ts index 6b7b15c..0f17da9 100644 --- a/app/src/controllers/webhooks.controller.ts +++ b/app/src/controllers/webhooks.controller.ts @@ -7,33 +7,17 @@ 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 { ApiOperation, ApiTags } from '@nestjs/swagger'; -import { ConfigService } from '@nestjs/config'; +import { DataSource } from 'typeorm'; -const config = new ConfigService(); - -/** - * Controller to handle incoming webhooks from various services. - */ -@ApiTags('Payment Webhooks') -@Controller({ path: 'webhooks', version: config.get('API_VERSION') }) +@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. - */ - @ApiOperation({ summary: 'Stripe payment webhook handler' }) @Post('stripe') async handleStripeWebhook( @Body() payload: any, @@ -52,19 +36,12 @@ export class WebhooksController { switch (event.type) { case 'checkout.session.completed': const session = event.data.object as Stripe.Checkout.Session; - await this.paymentService.handleSuccessfulPayment( - session.subscription as string, - session.amount_total, - PaymentMethodCode.STRIPE, - ); + await this.handleCheckoutSessionCompleted(session); break; case 'invoice.payment_failed': - const failedSession = event.data.object as Stripe.Invoice; - await this.paymentService.handleFailedPayment( - failedSession.subscription as string, - ); + const failedInvoice = event.data.object as Stripe.Invoice; + await this.handleInvoicePaymentFailed(failedInvoice); break; - // Handle other event types... default: console.log(`Unhandled event type ${event.type}`); } @@ -77,4 +54,71 @@ 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 ece220d..1eb6ec3 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, IsNumber } from 'class-validator'; +import { IsUUID, IsString } from 'class-validator'; export class CreatePaymentDto { @ApiProperty({ description: 'UUID of the invoice' }) @IsUUID() invoiceId: string; - @ApiProperty({ description: 'Amount of the payment' }) - @IsNumber() - amount: number; + @ApiProperty({ description: 'Payment method ID' }) + @IsString() + paymentMethodId: string; } diff --git a/app/src/entities/base.entity.ts b/app/src/entities/base.entity.ts index a0844aa..fe03c00 100644 --- a/app/src/entities/base.entity.ts +++ b/app/src/entities/base.entity.ts @@ -5,11 +5,8 @@ import { DeleteDateColumn, ManyToOne, BaseEntity as TypeORMBaseEntity, - BeforeInsert, } from 'typeorm'; import { DataLookup } from './data-lookup.entity'; -import { DataLookupService } from '../services/data-lookup.service'; -import { ObjectState } from '../utils/enums'; export abstract class BaseEntity extends TypeORMBaseEntity { @PrimaryGeneratedColumn('uuid') @@ -26,15 +23,4 @@ export abstract class BaseEntity extends TypeORMBaseEntity { @DeleteDateColumn({ nullable: true }) deletedDate: Date; - - constructor(private readonly dataLookupService: DataLookupService) { - super(); - } - - @BeforeInsert() - async setObjectState() { - if (!this.objectState) { - this.objectState = await this.dataLookupService.getDefaultData(ObjectState.TYPE); - } - } } diff --git a/app/src/interceptors/transaction.interceptor.ts b/app/src/interceptors/transaction.interceptor.ts index 46cf780..2d43051 100644 --- a/app/src/interceptors/transaction.interceptor.ts +++ b/app/src/interceptors/transaction.interceptor.ts @@ -1,4 +1,9 @@ -import { Injectable, NestInterceptor, ExecutionContext, CallHandler } from '@nestjs/common'; +import { + Injectable, + NestInterceptor, + ExecutionContext, + CallHandler, +} from '@nestjs/common'; import { Observable, from } from 'rxjs'; import { DataSource } from 'typeorm'; import { mergeMap } from 'rxjs/operators'; @@ -10,31 +15,39 @@ import { mergeMap } from 'rxjs/operators'; */ @Injectable() export class TransactionInterceptor implements NestInterceptor { - /** - * Constructor to inject the TypeORM data source. - * @param {DataSource} dataSource - The TypeORM data source instance to manage transactions. - */ - constructor(private readonly dataSource: DataSource) { } + /** + * Constructor to inject the TypeORM data source. + * @param {DataSource} dataSource - The TypeORM data source instance to manage transactions. + */ + constructor(private readonly dataSource: DataSource) {} - /** - * Intercepts the execution context (HTTP request) and wraps it in a database transaction. - * - * @param {ExecutionContext} context - The current execution context of the request. - * @param {CallHandler} next - The next handler in the request pipeline. - * @returns {Observable} - The resulting observable after the transaction is applied. - */ - intercept(context: ExecutionContext, next: CallHandler): Observable { - const request = context.switchToHttp().getRequest(); + /** + * Intercepts the execution context (HTTP request) and wraps it in a database transaction. + * + * @param {ExecutionContext} context - The current execution context of the request. + * @param {CallHandler} next - The next handler in the request pipeline. + * @returns {Observable} - The resulting observable after the transaction is applied. + */ + intercept(context: ExecutionContext, next: CallHandler): Observable { + const request = context.switchToHttp().getRequest(); - // Begin a new transaction using the TypeORM data source. - return from(this.dataSource.transaction(async (manager) => { - // Attach the transaction manager to the request object for use in downstream services. - request.transactionManager = manager; - // Handle the request and convert the result to a Promise. - return next.handle().toPromise(); - })).pipe( - // Use mergeMap to ensure the final result is an observable that can handle inner observables. - mergeMap(result => result) - ); - } + // Begin a new transaction using the TypeORM data source. + return from( + this.dataSource.transaction(async (manager) => { + // Attach the transaction manager to the request object for use in downstream services. + request.transactionManager = manager; + // Return the observable directly from the next.handle() method. + return next.handle().toPromise(); + }), + ).pipe( + // Use mergeMap to ensure the final result is an observable that can handle inner observables. + mergeMap((result) => { + // If result is null or undefined, provide a fallback to avoid the error. + if (result === null || result === undefined) { + return from([null]); + } + return from([result]); + }), + ); + } } diff --git a/app/src/processors/billing.processor.ts b/app/src/processors/billing.processor.ts index 7cf2e48..87d29d2 100644 --- a/app/src/processors/billing.processor.ts +++ b/app/src/processors/billing.processor.ts @@ -2,6 +2,7 @@ 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, @@ -9,7 +10,10 @@ import { JobQueues } from '../utils/enums'; */ @Processor(JobQueues.BILLING) export class BillingProcessor { - constructor(private readonly billingService: BillingService) {} + constructor( + private readonly billingService: BillingService, + private readonly dataSource: DataSource, + ) {} /** * Handles the 'generateInvoice' job from the billing queue. @@ -21,14 +25,22 @@ export class BillingProcessor { const { subscriptionId } = job.data; try { - const subscription = - await this.billingService.getSubscriptionById(subscriptionId); - if (subscription) { - await this.billingService.createInvoiceForSubscription(subscription); - } else { - // Handle case where subscription is not found - console.warn(`Subscription with ID ${subscriptionId} not found.`); - } + // Start a transaction + await this.dataSource.transaction(async (manager) => { + const subscription = await this.billingService.getSubscriptionById( + subscriptionId, + manager, + ); + if (subscription) { + await this.billingService.createInvoiceForSubscription( + subscription, + manager, + ); + } else { + // Handle case where subscription is not found + console.warn(`Subscription with ID ${subscriptionId} not found.`); + } + }); } catch (error) { console.error( `Failed to generate invoice for subscription ID ${subscriptionId}:`, diff --git a/app/src/processors/payment.processor.ts b/app/src/processors/payment.processor.ts index c089c12..70a91dc 100644 --- a/app/src/processors/payment.processor.ts +++ b/app/src/processors/payment.processor.ts @@ -2,40 +2,62 @@ 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) {} + constructor( + private readonly paymentService: PaymentService, + private readonly dataSource: DataSource, + ) {} - /** - * 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); + const result = await this.paymentService.retryPayment( + subscriptionId, + queryRunner.manager, + ); if (!result.success) { console.log( `Retry #${attempt} for subscription ID ${subscriptionId} failed`, ); - await this.paymentService.scheduleRetry(subscriptionId, attempt + 1); + await this.paymentService.scheduleRetry( + subscriptionId, + attempt + 1, + queryRunner.manager, + ); } else { console.log(`Payment successful for subscription ID ${subscriptionId}`); - await this.paymentService.confirmPayment(subscriptionId); + await this.paymentService.confirmPayment( + subscriptionId, + queryRunner.manager, + ); } + + 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/auth.service.ts b/app/src/services/auth.service.ts index e994135..97e2b6f 100644 --- a/app/src/services/auth.service.ts +++ b/app/src/services/auth.service.ts @@ -4,6 +4,7 @@ import { UsersService } from './users.service'; import { CreateUserDto, LoginDto } from '../dtos/user.dto'; import { User } from '../entities/user.entity'; import * as bcrypt from 'bcryptjs'; +import { EntityManager } from 'typeorm'; @Injectable() export class AuthService { @@ -20,12 +21,17 @@ export class AuthService { */ async validateUser( loginDto: LoginDto, + manager: EntityManager, ): Promise<{ user: Omit; access_token: string } | null> { - const user = await this.usersService.findOneByEmail(loginDto.email); + const user = await this.usersService.findOneByEmail( + loginDto.email, + manager, + ); + console.log('🚀 ~ AuthService ~ manager:', manager); if (user && (await this.verifyPassword(loginDto.password, user.password))) { const { password, ...userWithoutPassword } = user; - console.log('🚀 ~ AuthService ~ password:', password); + console.log('🚀 ~ AuthService ~ password:', password, manager); const access_token = await this.generateJwtToken(user); return { user: userWithoutPassword as User, access_token }; } @@ -38,7 +44,10 @@ export class AuthService { * @param user - The user object. * @returns A Promise that resolves to an object containing the JWT access token. */ - async login(user: User): Promise<{ access_token: string }> { + async login( + user: User, + manager: EntityManager, + ): Promise<{ access_token: string }> { const access_token = await this.generateJwtToken(user); return { access_token }; } @@ -49,8 +58,11 @@ export class AuthService { * @param createUserDto - An object containing the new user's registration details. * @returns A Promise that resolves to the created User entity. */ - async register(createUserDto: CreateUserDto): Promise { - return await this.usersService.create(createUserDto); + async register( + createUserDto: CreateUserDto, + manager: EntityManager, + ): Promise { + return await this.usersService.create(createUserDto, manager); } /** diff --git a/app/src/services/billing.service.ts b/app/src/services/billing.service.ts index ebdbf1a..5b98569 100644 --- a/app/src/services/billing.service.ts +++ b/app/src/services/billing.service.ts @@ -1,14 +1,21 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { EntityManager, Repository } from 'typeorm'; import { CustomerSubscription } from '../entities/customer.entity'; import { Invoice } from '../entities/invoice.entity'; import { DataLookup } from '../entities/data-lookup.entity'; import { SubscriptionPlan } from '../entities/subscription.entity'; -import { InvoiceStatus, JobQueues, SubscriptionStatus } from '../utils/enums'; +import { + InvoiceStatus, + JobQueues, + ObjectState, + SubscriptionStatus, +} from '../utils/enums'; import { InjectQueue } from '@nestjs/bull'; import { Queue } from 'bull'; import { NotificationsService } from './notifications.service'; +import * as dayjs from 'dayjs'; +import { DataLookupService } from './data-lookup.service'; @Injectable() export class BillingService { @@ -23,6 +30,7 @@ export class BillingService { private readonly subscriptionPlanRepository: Repository, @InjectQueue(JobQueues.BILLING) private readonly billingQueue: Queue, private readonly notificationsService: NotificationsService, + private readonly dataLookupService: DataLookupService, ) {} /** @@ -53,26 +61,39 @@ export class BillingService { */ async createInvoiceForSubscription( subscription: CustomerSubscription, - ): Promise { + manager: EntityManager, + ): Promise { const invoiceStatus = await this.getInvoiceStatus(InvoiceStatus.PENDING); + const objectDefaultState = await this.dataLookupService.getDefaultData( + ObjectState.TYPE, + ); - const invoice = this.invoiceRepository.create({ + const code = await this.generateInvoiceCode(); + const invoice = manager.create(Invoice, { + code, customerId: subscription.user.id, amount: subscription.subscriptionPlan.price, status: invoiceStatus, + subscription, + objectState: objectDefaultState, + //TODO [FUTURE]: payment due date has to be configured from system settings paymentDueDate: this.calculateNextBillingDate( new Date(), subscription.subscriptionPlan.billingCycleDays, ), - subscription: subscription, }); - await this.invoiceRepository.save(invoice); + await manager.save(Invoice, invoice); // Send notification after invoice is generated await this.notificationsService.sendInvoiceGeneratedEmail( + subscription.user.name, subscription.user.email, - invoice.id, + subscription.subscriptionPlan.name, + invoice.amount.toString(), + dayjs(Date.now()).format('MMMM D'), + this.generateBillingPeriod(Date.now(), subscription.nextBillingDate), + `https://media.saas.billing/subscriptions/invoices/${invoice.id}`, ); // Update subscription's next billing date @@ -80,7 +101,8 @@ export class BillingService { subscription.nextBillingDate, subscription.subscriptionPlan.billingCycleDays, ); - await this.customerSubscriptionRepository.save(subscription); + await manager.save(CustomerSubscription, subscription); + return invoice; } /** @@ -93,8 +115,12 @@ export class BillingService { async handleSubscriptionChange( subscriptionId: string, newPlanId: string, + manager: EntityManager, ): Promise { - const subscription = await this.findSubscriptionById(subscriptionId); + const subscription = await this.findSubscriptionById( + subscriptionId, + manager, + ); const newPlan = await this.findSubscriptionPlanById(newPlanId); const proratedAmount = this.calculateProratedAmount( @@ -119,8 +145,9 @@ export class BillingService { */ async getSubscriptionById( subscriptionId: string, + manager: EntityManager, ): Promise { - return await this.findSubscriptionById(subscriptionId); + return await this.findSubscriptionById(subscriptionId, manager); } /** @@ -224,8 +251,9 @@ export class BillingService { */ private async findSubscriptionById( subscriptionId: string, + manager: EntityManager, ): Promise { - const subscription = await this.customerSubscriptionRepository.findOne({ + const subscription = await manager.findOne(CustomerSubscription, { where: { id: subscriptionId }, }); if (!subscription) { @@ -256,4 +284,16 @@ export class BillingService { } return plan; } + + private generateBillingPeriod(startDate, endDate) { + const start = dayjs(startDate).format('MMMM D'); // e.g., "July 1st" + const end = dayjs(endDate).format('MMMM D'); // e.g., "September 30th" + return `${start} to ${end}`; + } + + private async generateInvoiceCode() { + const count = await this.invoiceRepository.count(); + const paddedCount = (count + 1).toString().padStart(4, '0'); + return `INV-${paddedCount}`; + } } diff --git a/app/src/services/data-lookup.service.ts b/app/src/services/data-lookup.service.ts index 330acd0..9d8efba 100644 --- a/app/src/services/data-lookup.service.ts +++ b/app/src/services/data-lookup.service.ts @@ -92,7 +92,9 @@ export class DataLookupService { defaultStateType: string, manager?: EntityManager, ): Promise { - const repo = manager ? manager.getRepository(DataLookup) : this.lookupRepository; + const repo = manager + ? manager.getRepository(DataLookup) + : this.lookupRepository; const defaultState = await repo.findOne({ where: { type: defaultStateType, is_default: true }, }); diff --git a/app/src/services/notifications.service.ts b/app/src/services/notifications.service.ts index 51cdf41..47efff4 100644 --- a/app/src/services/notifications.service.ts +++ b/app/src/services/notifications.service.ts @@ -67,12 +67,50 @@ export class NotificationsService { * @returns A Promise that resolves when the email is sent. */ async sendInvoiceGeneratedEmail( + name: string, email: string, subscriptionPlan: string, + invoiceAmount: string, + invoiceDate: string, + billingPeriod: string, + invoiceLink: string, ): Promise { - const subject = 'Invoice Generated'; - const text = `Your invoice for ${subscriptionPlan} subscription has been generated.`; - const html = `

Your invoice for ${subscriptionPlan} subscription has been generated.

`; + const subject = 'Your Invoice is Ready!'; + + const text = `Dear ${name}, + + Your invoice for the ${subscriptionPlan} subscription has been generated. + + Details: + - Subscription Plan: ${subscriptionPlan} + - Invoice Amount: ${invoiceAmount} + - Invoice Date: ${invoiceDate} + - Billing Period: ${billingPeriod} + + You can view and download your invoice using the following link: ${invoiceLink}. + + If you have any questions or need assistance, please contact our support team. + + Thank you for your continued business! + + Best regards, + The SaaS Company`; + + const html = ` +

Dear ${name},

+

Your invoice for the ${subscriptionPlan} subscription has been generated.

+

Details:

+
    +
  • Subscription Plan: ${subscriptionPlan}
  • +
  • Invoice Amount: ${invoiceAmount}
  • +
  • Invoice Date: ${invoiceDate}
  • +
  • Billing Period: ${billingPeriod}
  • +
+

You can view and download your invoice using the following link: View Invoice.

+

If you have any questions or need assistance, please contact our support team.

+

Thank you for your continued business!

+

Best regards,
The SaaS Company

`; + await this.sendEmail(email, subject, text, html); } @@ -81,15 +119,50 @@ export class NotificationsService { * * @param email - The recipient's email address. * @param subscriptionPlan - The name of the subscription plan for which the payment was successful. + * @param amountPaid - The amount that was successfully paid. + * @param transactionDate - The date of the successful payment transaction. + * @param invoiceLink - A link to view the invoice or transaction details. * @returns A Promise that resolves when the email is sent. */ async sendPaymentSuccessEmail( + name: string, email: string, subscriptionPlan: string, + amountPaid: string, + transactionDate: string, + invoiceLink: string, ): Promise { - const subject = 'Payment Successful'; - const text = `Your subscription payment for ${subscriptionPlan} was successful.`; - const html = `

Your subscription payment for ${subscriptionPlan} was successful.

`; + const subject = 'Payment Confirmation - Thank You for Your Purchase!'; + + const text = `Dear ${name}, + +We are pleased to inform you that your payment for the ${subscriptionPlan} subscription was successful. + +Payment Details: +- Subscription Plan: ${subscriptionPlan} +- Amount Paid: ${amountPaid} +- Transaction Date: ${transactionDate} + +You can view and download your invoice using the following link: ${invoiceLink}. + +Thank you for your prompt payment! If you have any questions or need assistance, please don't hesitate to contact our support team. + +Best regards, +The SaaS Company`; + + const html = ` +

Dear ${name},

+

We are pleased to inform you that your payment for the ${subscriptionPlan} subscription was successful.

+

Payment Details:

+
    +
  • Subscription Plan: ${subscriptionPlan}
  • +
  • Amount Paid: ${amountPaid}
  • +
  • Transaction Date: ${transactionDate}
  • +
+

You can view and download your invoice using the following link: View Invoice.

+

Thank you for your prompt payment! If you have any questions or need assistance, please don't hesitate to contact our support team.

+

Best regards,
The SaaS Company

`; + await this.sendEmail(email, subject, text, html); } diff --git a/app/src/services/payment.service.ts b/app/src/services/payment.service.ts index 4a4a274..3182e38 100644 --- a/app/src/services/payment.service.ts +++ b/app/src/services/payment.service.ts @@ -1,6 +1,10 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; +import { + Injectable, + InternalServerErrorException, + NotFoundException, +} from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { DataSource, EntityManager, Repository } from 'typeorm'; import { Invoice } from '../entities/invoice.entity'; import { Payment } from '../entities/payment.entity'; import { CustomerSubscription } from '../entities/customer.entity'; @@ -8,6 +12,7 @@ import { StripeService } from './stripe.service'; import { InvoiceStatus, JobQueues, + ObjectState, PaymentMethodCode, PaymentRetrySettings, PaymentStatus, @@ -20,6 +25,9 @@ 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'; +import { DataLookupService } from './data-lookup.service'; +import * as dayjs from 'dayjs'; @Injectable() export class PaymentService { @@ -39,110 +47,239 @@ export class PaymentService { @InjectQueue(JobQueues.PAYMENT_RETRY) private paymentRetryQueue: Queue, private readonly stripeService: StripeService, private readonly notificationsService: NotificationsService, + private readonly dataLookupService: DataLookupService, + private readonly dataSource: DataSource, ) {} - /** - * 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({ + 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, { where: { - subscription: { id: subscriptionId }, + id: invoiceId, status: { value: InvoiceStatus.PENDING }, }, - relations: ['subscription'], + relations: [ + 'subscription', + 'subscription.user', + 'subscription.subscriptionPlan', + ], }); if (!invoice) { - throw new NotFoundException( - `Invoice not found for subscription ID ${subscriptionId}`, - ); + throw new NotFoundException('Invoice not found or already been paid.'); } + return invoice; + } - const verifiedPaymentStatus = await this.dataLookupRepository.findOne({ - where: { value: PaymentStatus.VERIFIED }, - }); - const paymentMethod = await this.paymentMethodRepository.findOne({ - where: { code: paymentMethodCode }, + 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, + }, }); + } + + 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.name, + invoice.subscription.user.email, + invoice.subscription.subscriptionPlan.name, + invoice.amount.toString(), + dayjs(invoice.paymentDate).format('MMMM D, YY'), + `https://media.saas.billing/subscriptions/invoices/${invoice.id}`, + ); + } - const payment = this.paymentRepository.create({ + 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 objectDefaultState = await this.dataLookupService.getDefaultData( + ObjectState.TYPE, + ); + + const payment = manager.create(Payment, { invoice, paymentMethod, status: verifiedPaymentStatus, - amount: paymentAmount, + objectState: objectDefaultState, + amount: invoice.amount, + referenceNumber: paymentIntent.id, + payerName: this.getCustomerInfo(paymentIntent.customer), paymentDate: new Date().toISOString(), }); - await this.paymentRepository.save(payment); + await manager.save(Payment, payment); + } - const paidInvoiceStatus = await this.dataLookupRepository.findOne({ - where: { value: InvoiceStatus.PAID }, - }); - invoice.status = paidInvoiceStatus; + private async updateInvoiceStatus( + invoice: Invoice, + statusValue: InvoiceStatus, + manager: EntityManager, + ) { + const status = await this.findDataLookupByValue(statusValue, manager); + invoice.status = status; invoice.paymentDate = new Date(); - await this.invoiceRepository.save(invoice); + await manager.save(Invoice, invoice); + } - await this.notificationsService.sendPaymentSuccessEmail( - invoice.subscription.user.email, - invoice.subscription.subscriptionPlan.name, + 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); } - /** - * 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'], - }); + private async findDataLookupByValue( + value: string, + manager: EntityManager, + ): Promise { + return manager.findOne(DataLookup, { where: { value } }); + } - if (!subscription) { - throw new NotFoundException( - `Subscription with ID ${subscriptionId} not found`, + private async findPaymentMethodByCode( + code: string, + manager: EntityManager, + ): Promise { + return manager.findOne(PaymentMethod, { where: { code } }); + } + + 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.dataLookupRepository.findOne({ - where: { - type: SubscriptionStatus.TYPE, - value: SubscriptionStatus.OVERDUE, - }, - }); + const overdueStatus = await this.findDataLookupByValue( + SubscriptionStatus.OVERDUE, + manager, + ); - subscription.subscriptionStatus = overdueStatus; - await this.customerSubscriptionRepository.save(subscription); + subscription.subscriptionStatus = overdueStatus; + await manager.save(CustomerSubscription, subscription); - await this.notificationsService.sendPaymentFailureEmail( - subscription.user.email, - subscription.subscriptionPlan.name, - ); - await this.scheduleRetry(subscriptionId); + await this.notificationsService.sendPaymentFailureEmail( + subscription.user.email, + subscription.subscriptionPlan.name, + ); + + await this.scheduleRetry(subscriptionId, 1, manager); + } catch (error) { + if (error instanceof NotFoundException) { + throw error; // Re-throw specific known exceptions + } + throw new InternalServerErrorException( + 'Failed to handle failed payment.', + ); + } } - /** - * 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({ + private async findSubscriptionById( + subscriptionId: string, + manager: EntityManager, + ): Promise { + const subscription = await manager.findOne(CustomerSubscription, { where: { id: subscriptionId }, + relations: ['subscriptionStatus'], }); if (!subscription) { @@ -150,50 +287,91 @@ export class PaymentService { `Subscription with ID ${subscriptionId} not found`, ); } + return subscription; + } - const maxRetriesSetting = await this.settingRepository.findOne({ - where: { code: PaymentRetrySettings.MAX_RETRIES }, - }); - if (subscription.retryCount >= parseInt(maxRetriesSetting.currentValue)) { + 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.`, ); return; } - const retryDelaySetting = await this.settingRepository.findOne({ - where: { code: PaymentRetrySettings.RETRY_DELAY_MINUTES }, - }); - const nextRun = parseInt(retryDelaySetting.currentValue) * 60 * 1000; + const retryDelay = await this.getSystemSetting( + PaymentRetrySettings.RETRY_DELAY_MINUTES, + manager, + ); + const nextRun = parseInt(retryDelay.currentValue) * 60 * 1000; await this.paymentRetryQueue.add( - { - subscriptionId, - attempt, - }, - { - delay: nextRun, - }, + { subscriptionId, attempt }, + { delay: nextRun }, ); subscription.retryCount = attempt; subscription.nextRetry = new Date(Date.now() + nextRun); - await this.customerSubscriptionRepository.save(subscription); + 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}`, ); } - /** - * 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({ + 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 }; + } + } + + private async findFailedInvoice( + subscriptionId: string, + manager: EntityManager, + ): Promise { + const invoice = await manager.findOne(Invoice, { where: { subscription: { id: subscriptionId }, status: { value: InvoiceStatus.FAILED }, @@ -206,69 +384,45 @@ export class PaymentService { `No unpaid invoice found for subscription ID ${subscriptionId}`, ); } - - 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 }; - } + return invoice; } - /** - * 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 }, + 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, + }, }); } - /** - * 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. - */ + 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.'); + } + + subscription.subscriptionStatus = activeStatus; + await manager.save(CustomerSubscription, subscription); + } + getCustomerInfo( customer: string | Stripe.Customer | Stripe.DeletedCustomer | null, ): string | null { @@ -283,36 +437,16 @@ export class PaymentService { return 'Stripe Customer'; } - /** - * 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({ + async findInvoiceById( + invoiceId: string, + manager: EntityManager, + ): Promise { + return manager.findOne(Invoice, { where: { - type: SubscriptionStatus.TYPE, - value: SubscriptionStatus.ACTIVE, + id: invoiceId, + status: { value: InvoiceStatus.PENDING }, }, + 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/setting.service.ts b/app/src/services/setting.service.ts index 2ad07fc..3d56cb3 100644 --- a/app/src/services/setting.service.ts +++ b/app/src/services/setting.service.ts @@ -107,7 +107,10 @@ export class SystemSettingService extends GenericService { * @throws NotFoundException if the system setting is not found. * @throws BadRequestException if the current value is already the same as the default value. */ - async resetSetting(code: string, manager: EntityManager): Promise { + async resetSetting( + code: string, + manager: EntityManager, + ): Promise { const setting = await manager.findOne(SystemSetting, { where: { code }, }); diff --git a/app/src/services/subscription-plan.service.ts b/app/src/services/subscription-plan.service.ts index 1689711..2652639 100644 --- a/app/src/services/subscription-plan.service.ts +++ b/app/src/services/subscription-plan.service.ts @@ -8,15 +8,15 @@ import { UpdateSubscriptionPlanDto, } from '../dtos/subscription.dto'; import { GenericService } from './base.service'; -import { SubscriptionPlanState } from '../utils/enums'; +import { ObjectState, SubscriptionPlanState } from '../utils/enums'; +import { DataLookupService } from './data-lookup.service'; @Injectable() export class SubscriptionPlanService extends GenericService { constructor( @InjectRepository(SubscriptionPlan) private readonly subscriptionPlanRepository: Repository, - @InjectRepository(DataLookup) - private readonly dataLookupRepository: Repository, + private readonly dataLookupService: DataLookupService, dataSource: DataSource, ) { super(SubscriptionPlan, dataSource); @@ -38,6 +38,10 @@ export class SubscriptionPlanService extends GenericService { ); } + const objectDefaultState = await this.dataLookupService.getDefaultData( + ObjectState.TYPE, + ); + const newPlan = this.subscriptionPlanRepository.create({ name, description, @@ -45,6 +49,7 @@ export class SubscriptionPlanService extends GenericService { billingCycleDays, status: planDefaultState, prorate, + objectState: objectDefaultState, }); return manager.save(SubscriptionPlan, newPlan); diff --git a/app/src/services/subscription.service.ts b/app/src/services/subscription.service.ts index ddb72c6..d6f98bf 100644 --- a/app/src/services/subscription.service.ts +++ b/app/src/services/subscription.service.ts @@ -1,49 +1,84 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { EntityManager, Repository, DataSource } from 'typeorm'; +import { EntityManager, Repository, DataSource, In } from 'typeorm'; import { CustomerSubscription } from '../entities/customer.entity'; import { User } from '../entities/user.entity'; import { SubscriptionPlan } from '../entities/subscription.entity'; import { DataLookup } from '../entities/data-lookup.entity'; import { - CreateSubscriptionDto, + CreateSubscriptionDto, UpdateSubscriptionStatusDto, } from '../dtos/subscription.dto'; import { GenericService } from './base.service'; -import { SubscriptionStatus } from '../utils/enums'; +import { + ObjectState, + SubscriptionPlanState, + SubscriptionStatus, +} from '../utils/enums'; +import { DataLookupService } from './data-lookup.service'; +import { BillingService } from './billing.service'; @Injectable() export class CustomerSubscriptionService extends GenericService { constructor( @InjectRepository(CustomerSubscription) - private readonly customerSubscriptionRepository: Repository, + private readonly customerSubscriptionRepository: Repository, + private readonly dataLookupService: DataLookupService, + private readonly billingService: BillingService, dataSource: DataSource, ) { - super(CustomerSubscription, dataSource); + super(CustomerSubscription, dataSource); } async createCustomerSubscription( createSubscriptionDto: CreateSubscriptionDto, - manager: EntityManager, + manager: EntityManager, ): Promise { const { userId, subscriptionPlanId } = createSubscriptionDto; - const user = await manager.findOne(User, { where: { id: userId } }); + const user = await manager.findOne(User, { where: { id: userId } }); if (!user) { throw new NotFoundException(`User with ID ${userId} not found`); } - const subscriptionPlan = await manager.findOne(SubscriptionPlan, { - where: { id: subscriptionPlanId }, + const subscriptionPlan = await manager.findOne(SubscriptionPlan, { + where: { id: subscriptionPlanId }, + relations: ['status'], }); - if (!subscriptionPlan) { + if ( + !subscriptionPlan || + subscriptionPlan.status.value != SubscriptionPlanState.ACTIVE + ) { throw new NotFoundException( - `Subscription plan with ID ${subscriptionPlanId} not found`, + `Active subscription plan with ID ${subscriptionPlanId} not found`, + ); + } + + // Check if the user already has an active subscription for this plan + // Assuming that the user can only subscribe to same plan if their subscription is in the + // allowed list + const resubscriptionAllowedList = [ + SubscriptionStatus.CANCELLED, + SubscriptionStatus.ARCHIVED, + ]; + const existingSubscription = await manager.findOne(CustomerSubscription, { + where: { + user: { id: userId }, + subscriptionPlan: { id: subscriptionPlanId }, + subscriptionStatus: { + value: In(resubscriptionAllowedList), + }, + }, + }); + + if (existingSubscription) { + throw new Error( + `You can't subscribe to ${subscriptionPlan.name}. Please process your exisiting subscription in the pipeline.`, ); } - const subscriptionStatus = await manager.findOne(DataLookup, { - where: { value: SubscriptionStatus.PENDING }, + const subscriptionStatus = await manager.findOne(DataLookup, { + where: { value: SubscriptionStatus.PENDING }, }); if (!subscriptionStatus) { throw new NotFoundException( @@ -51,29 +86,43 @@ export class CustomerSubscriptionService extends GenericService { - const user = await manager.findOne(User, { where: { id: userId } }); + const user = await manager.findOne(User, { where: { id: userId } }); if (!user) { throw new NotFoundException(`User with ID ${userId} not found`); } - return manager.find(CustomerSubscription, { + return manager.find(CustomerSubscription, { where: { user }, relations: ['subscriptionPlan', 'subscriptionStatus'], }); @@ -82,11 +131,11 @@ export class CustomerSubscriptionService extends GenericService { const { subscriptionStatusId, endDate } = updateSubscriptionStatusDto; - const subscription = await manager.findOne(CustomerSubscription, { + const subscription = await manager.findOne(CustomerSubscription, { where: { id: subscriptionId }, relations: ['subscriptionStatus'], }); @@ -97,8 +146,8 @@ export class CustomerSubscriptionService extends GenericService { constructor( @InjectRepository(User) private readonly userRepository: Repository, + private readonly dataLookupService: DataLookupService, dataSource: DataSource, ) { super(User, dataSource); @@ -21,8 +24,11 @@ export class UsersService extends GenericService { * @param email - The email address of the user to find. * @returns A Promise that resolves to the found User entity or undefined if not found. */ - async findOneByEmail(email: string): Promise { - return this.userRepository.findOne({ where: { email } }); + async findOneByEmail( + email: string, + manager: EntityManager, + ): Promise { + return manager.findOneBy(User, { email }); } /** @@ -31,8 +37,12 @@ export class UsersService extends GenericService { * @param id - The ID of the user to find. * @returns A Promise that resolves to the found User entity or undefined if not found. */ - async findOne(id: string): Promise { - return this.userRepository.findOne({ where: { id } }); + async findOne( + id: string, + manager?: EntityManager, + ): Promise { + const repo = manager ? manager.getRepository(User) : this.userRepository; + return repo.findOne({ where: { id } }); } /** @@ -41,13 +51,17 @@ export class UsersService extends GenericService { * @param user - The partial User entity containing the user data. * @returns A Promise that resolves to the newly created User entity. */ - async create(user: Partial): Promise { + async create(user: Partial, manager: EntityManager): Promise { const hashedPassword = await this.hashPassword(user.password); - const newUser = this.userRepository.create({ + const objectState = await this.dataLookupService.getDefaultData( + ObjectState.TYPE, + ); + const newUser = manager.create(User, { ...user, password: hashedPassword, + objectState, }); - return this.userRepository.save(newUser); + return manager.save(User, newUser); } /**