diff --git a/package.json b/package.json index a6f832c1..97210854 100644 --- a/package.json +++ b/package.json @@ -59,7 +59,7 @@ "file-type-checker": "^1.0.8", "fp-ts": "^2.16.1", "jsonwebtoken": "^9.0.0", - "le-coffre-resources": "git@github.com:smart-chain-fr/leCoffre-resources.git#v2.119", + "le-coffre-resources": "git@github.com:smart-chain-fr/leCoffre-resources.git#v2.126", "module-alias": "^2.2.2", "monocle-ts": "^2.3.13", "multer": "^1.4.5-lts.1", @@ -70,6 +70,7 @@ "prisma-query": "^2.0.0", "puppeteer": "^21.3.4", "reflect-metadata": "^0.1.13", + "stripe": "^14.22.0", "ts-node": "^10.9.1", "tslib": "^2.4.1", "typedi": "^0.10.0", diff --git a/src/app/api/admin/StripeController.ts b/src/app/api/admin/StripeController.ts new file mode 100644 index 00000000..ae4c211d --- /dev/null +++ b/src/app/api/admin/StripeController.ts @@ -0,0 +1,42 @@ +import authHandler from "@App/middlewares/AuthHandler"; +// import roleHandler from "@App/middlewares/RolesHandler"; +import ApiController from "@Common/system/controller-pattern/ApiController"; +import { Controller, Post } from "@ControllerPattern/index"; +import StripeService from "@Services/common/StripeService/StripeService"; +import { validateOrReject } from "class-validator"; +import { Request, Response } from "express"; +import { Subscription } from "le-coffre-resources/dist/Admin"; +import { Service } from "typedi"; + +@Controller() +@Service() +export default class StripeController extends ApiController { + constructor(private stripeService: StripeService) { + super(); + } + + /** + * @description Create a new checkout session + */ + @Post("/api/v1/admin/stripe", [authHandler]) + protected async post(req: Request, response: Response) { + try { + const officeId: string = req.body.user.office_Id; + + //add office id to request body + req.body.office = {uid: officeId}; + + //init Subscription resource with request body values + const subscriptionEntity = Subscription.hydrate(req.body, { strategy: "excludeAll" }); + + await validateOrReject(subscriptionEntity, { groups: ["createSubscription"], forbidUnknownValues: false }); + + const stripeSession = await this.stripeService.createCheckoutSession(subscriptionEntity); + + this.httpCreated(response, stripeSession); + } catch (error) { + this.httpInternalError(response, error); + return; + } + } +} \ No newline at end of file diff --git a/src/app/api/admin/SubscriptionsController.ts b/src/app/api/admin/SubscriptionsController.ts new file mode 100644 index 00000000..4de51386 --- /dev/null +++ b/src/app/api/admin/SubscriptionsController.ts @@ -0,0 +1,145 @@ +import { Controller, Get, Post, Put } from "@ControllerPattern/index"; +import { Response, Request } from "express"; +import ApiController from "@Common/system/controller-pattern/ApiController"; +import { Service } from "typedi"; +// import authHandler from "@App/middlewares/AuthHandler"; +// import roleHandler from "@App/middlewares/RolesHandler"; +// import ruleHandler from "@App/middlewares/RulesHandler"; +import { Prisma } from "@prisma/client"; +import SubscriptionsService from "@Services/admin/SubscriptionsService/SubscriptionsService.ts"; +import { Subscription } from "le-coffre-resources/dist/Admin"; +import { validateOrReject } from "class-validator"; +import ObjectHydrate from "@Common/helpers/ObjectHydrate"; +import roleHandler from "@App/middlewares/RolesHandler"; +import authHandler from "@App/middlewares/AuthHandler"; + +@Controller() +@Service() +export default class SubscriptionsController extends ApiController { + constructor(private subscriptionsService: SubscriptionsService) { + super(); + } + + /** + * @description Get all subscriptions + */ + @Get("/api/v1/admin/subscriptions", [authHandler, roleHandler]) + protected async get(req: Request, response: Response) { + try { + //get query + let query: Prisma.SubscriptionsFindManyArgs = {}; + if (req.query["q"]) { + query = JSON.parse(req.query["q"] as string); + if (query.where?.uid) { + this.httpBadRequest(response, "You can't filter by uid"); + return; + } + } + + //call service to get prisma entity + const subscriptionsEntities = await this.subscriptionsService.get(query); + + //Hydrate ressource with prisma entity + const subscriptions = Subscription.hydrateArray(subscriptionsEntities, { strategy: "excludeAll" }); + + //success + this.httpSuccess(response, subscriptions); + } catch (error) { + this.httpInternalError(response, error); + return; + } + } + + /** + * @description Get a specific documentType by uid + */ + @Get("/api/v1/admin/subscriptions/:uid", [authHandler, roleHandler]) + protected async getOneByUid(req: Request, response: Response) { + try { + const uid = req.params["uid"]; + if (!uid) { + this.httpBadRequest(response, "No uid provided"); + return; + } + //get query + let query; + if (req.query["q"]) { + query = JSON.parse(req.query["q"] as string); + } + + const subscriptionEntity = await this.subscriptionsService.getByUid(uid, query); + + //Hydrate resource with prisma entity + const subscription = ObjectHydrate.hydrate(new Subscription(), subscriptionEntity!, { strategy: "excludeAll" }); + + //success + this.httpSuccess(response, subscription); + } catch (error) { + this.httpInternalError(response, error); + return; + } + } + + /** + * @description Create a new documentType + */ + @Post("/api/v1/admin/subscriptions", [authHandler, roleHandler]) + protected async post(req: Request, response: Response) { + try { + //init Subscription resource with request body values + const subscriptionEntity = Subscription.hydrate(req.body); + //validate subscription + await validateOrReject(subscriptionEntity, { groups: ["createSubscription"], forbidUnknownValues: false }); + //call service to get prisma entity + const subscriptionEntityCreated = await this.subscriptionsService.create(subscriptionEntity); + //Hydrate ressource with prisma entity + const subscription = Subscription.hydrate(subscriptionEntityCreated, { + strategy: "excludeAll", + }); + //success + this.httpCreated(response, subscription); + } catch (error) { + this.httpInternalError(response, error); + return; + } + } + + /** + * @description Update a subscription + */ + @Put("/api/v1/admin/subscriptions/:uid", [authHandler, roleHandler]) + protected async put(req: Request, response: Response) { + try { + const uid = req.params["uid"]; + if (!uid) { + this.httpBadRequest(response, "No uid provided"); + return; + } + + const subscriptionFound = await this.subscriptionsService.getByUid(uid); + + if (!subscriptionFound) { + this.httpNotFoundRequest(response, "subscription not found"); + return; + } + + //init Subscription resource with request body values + const subscriptionEntity = Subscription.hydrate(req.body); + + //call service to get prisma entity + const subscriptionEntityUpdated = await this.subscriptionsService.update(uid, subscriptionEntity); + + //Hydrate ressource with prisma entity + const subscription = Subscription.hydrate(subscriptionEntityUpdated, { + strategy: "excludeAll", + }); + + //success + this.httpSuccess(response, subscription); + + } catch (error) { + this.httpInternalError(response, error); + return; + } + } +} diff --git a/src/app/api/idnot/OfficeController.ts b/src/app/api/idnot/OfficeController.ts index 3e00e7bf..32afb042 100644 --- a/src/app/api/idnot/OfficeController.ts +++ b/src/app/api/idnot/OfficeController.ts @@ -3,6 +3,8 @@ import { Controller, Get } from "@ControllerPattern/index"; import ApiController from "@Common/system/controller-pattern/ApiController"; import { Service } from "typedi"; import IdNotService from "@Services/common/IdNotService/IdNotService"; +import userHandler from "@App/middlewares/OfficeMembershipHandlers/UserHandler"; +import authHandler from "@App/middlewares/AuthHandler"; @Controller() @Service() @@ -11,16 +13,17 @@ export default class UserController extends ApiController { super(); } - @Get("/api/v1/idnot/office/:uid/office-memberships") + @Get("/api/v1/idnot/office/:uid/office-memberships", [authHandler, userHandler]) protected async getOfficeMemberships(req: Request, response: Response) { - try { + try { const uid = req.params["uid"]; if (!uid) { this.httpBadRequest(response, "uid is required"); return; } - const officeMemberships = await this.idNotService.getOfficeMemberships(uid); + const officeMemberships = await this.idNotService.getOfficeMemberships(uid); + this.httpSuccess(response, officeMemberships); } catch (error) { console.log(error); diff --git a/src/app/api/idnot/UserController.ts b/src/app/api/idnot/UserController.ts index 0521a747..fcf4b7b3 100644 --- a/src/app/api/idnot/UserController.ts +++ b/src/app/api/idnot/UserController.ts @@ -6,13 +6,17 @@ import AuthService, { IUserJwtPayload } from "@Services/common/AuthService/AuthS import IdNotService from "@Services/common/IdNotService/IdNotService"; import WhitelistService from "@Services/common/WhitelistService/WhitelistService"; -import User from "le-coffre-resources/dist/SuperAdmin"; +import User from "le-coffre-resources/dist/Admin"; import UsersService from "@Services/super-admin/UsersService/UsersService"; +import SubscriptionsService from "@Services/admin/SubscriptionsService/SubscriptionsService.ts"; +import { ESubscriptionStatus } from "@prisma/client"; +import SeatsService from "@Services/admin/SeatsService/SeatsService"; +import { EType } from "le-coffre-resources/dist/Admin/Subscription"; @Controller() @Service() export default class UserController extends ApiController { - constructor(private authService: AuthService, private idNotService: IdNotService, private whitelistService: WhitelistService, private userService: UsersService) { + constructor(private authService: AuthService, private idNotService: IdNotService, private whitelistService: WhitelistService, private userService: UsersService, private subscriptionsService: SubscriptionsService, private seatsService: SeatsService) { super(); } @@ -38,7 +42,7 @@ export default class UserController extends ApiController { const user = await this.idNotService.getOrCreateUser(idNotToken); if(!user) { - this.httpUnauthorized(response, "Email not found"); + this.httpUnauthorized(response, "User not found"); return; } @@ -61,9 +65,50 @@ export default class UserController extends ApiController { return; } - //Check if user is whitelisted + let isSubscribed = false; + const subscriptions = await this.subscriptionsService.get({ where: { office_uid: userHydrated.office_membership?.uid } }); - const isWhitelisted = await this.whitelistService.getByEmail(userHydrated.contact!.email); + if(!subscriptions || subscriptions.length === 0 || subscriptions[0]?.status === ESubscriptionStatus.INACTIVE) { + this.httpUnauthorized(response, "User not subscribed"); + isSubscribed = false; + return; + } + + if(subscriptions[0]?.type === EType.Unlimited) { + isSubscribed = true; + } + else{ + const hasSeat = await this.subscriptionsService.get({ where: {status: ESubscriptionStatus.ACTIVE, seats: {some : {user_uid : userHydrated.uid }} } }); + + if (hasSeat && hasSeat.length > 0) { + isSubscribed = true; + } + else { + const nbMaxSeats = subscriptions[0]!.nb_seats; + + const nbCurrentSeats = await this.seatsService.get({ where: { subscription_uid: subscriptions[0]!.uid }}); + + //if nbMaxSeats < nbCurrentSeats, create a new seat for the user + if (nbMaxSeats > nbCurrentSeats.length) { + const seatAdded = await this.seatsService.create(user.uid, subscriptions[0]!.uid); + if (seatAdded) { + isSubscribed = true; + } + } + } + } + + if(!isSubscribed) { + this.httpUnauthorized(response, "User not subscribed"); + return; + } + + //Check if user is whitelisted + const isWhitelisted = await this.whitelistService.getByEmail(userHydrated.contact!.email); + + + //When we'll switch to idNotId whitelisting + // const isWhitelisted = await this.userWhitelistService.getByIdNotId(user.idNot); //If not whitelisted, return 409 Not whitelisted if (!isWhitelisted || isWhitelisted.length === 0) { diff --git a/src/app/index.ts b/src/app/index.ts index 20343be2..174b9abd 100644 --- a/src/app/index.ts +++ b/src/app/index.ts @@ -49,6 +49,9 @@ import AuthController from "./api/customer/AuthController"; import NotaryOfficeRibController from "./api/notary/OfficeRibController"; import CustomerOfficeRibController from "./api/customer/OfficeRibController"; import IdNotOfficeController from "./api/idnot/OfficeController"; +import SubscriptionsController from "./api/admin/SubscriptionsController"; +import StripeController from "./api/admin/StripeController"; +import StripeWebhooks from "@Common/webhooks/stripeWebhooks"; /** * @description This allow to declare all controllers used in the application */ @@ -104,6 +107,9 @@ export default { Container.get(AuthController); Container.get(NotaryOfficeRibController); Container.get(CustomerOfficeRibController); - Container.get(IdNotOfficeController) + Container.get(IdNotOfficeController); + Container.get(SubscriptionsController); + Container.get(StripeController); + Container.get(StripeWebhooks); }, }; diff --git a/src/common/config/variables/Variables.ts b/src/common/config/variables/Variables.ts index 10df52e7..16872c03 100644 --- a/src/common/config/variables/Variables.ts +++ b/src/common/config/variables/Variables.ts @@ -142,6 +142,21 @@ export class BackendVariables { @IsNotEmpty() public readonly SCW_BUCKET_NAME!: string; + @IsNotEmpty() + public readonly STRIPE_SECRET_KEY!: string; + + @IsNotEmpty() + public readonly STRIPE_STANDARD_SUBSCRIPTION_PRICE_ID!: string; + + @IsNotEmpty() + public readonly STRIPE_UNLIMITED_SUBSCRIPTION_PRICE_ID!: string; + + @IsNotEmpty() + public readonly STRIPE_PAYMENT_SUCCESS_URL!: string; + + @IsNotEmpty() + public readonly STRIPE_PAYMENT_CANCEL_URL!: string; + public constructor() { dotenv.config(); this.DATABASE_PORT = process.env["DATABASE_PORT"]!; @@ -190,6 +205,11 @@ export class BackendVariables { this.SCW_ACCESS_KEY_SECRET = process.env["SCW_ACCESS_KEY_SECRET"]!; this.SCW_BUCKET_ENDPOINT = process.env["SCW_BUCKET_ENDPOINT"]!; this.SCW_BUCKET_NAME = process.env["SCW_BUCKET_NAME"]!; + this.STRIPE_SECRET_KEY = process.env["STRIPE_SECRET_KEY"]!; + this.STRIPE_STANDARD_SUBSCRIPTION_PRICE_ID = process.env["STRIPE_STANDARD_SUBSCRIPTION_PRICE_ID"]!; + this.STRIPE_UNLIMITED_SUBSCRIPTION_PRICE_ID = process.env["STRIPE_UNLIMITED_SUBSCRIPTION_PRICE_ID"]!; + this.STRIPE_PAYMENT_SUCCESS_URL = process.env["STRIPE_PAYMENT_SUCCESS_URL"]!; + this.STRIPE_PAYMENT_CANCEL_URL = process.env["STRIPE_PAYMENT_CANCEL_URL"]!; } public async validate(groups?: string[]) { const validationOptions = groups ? { groups } : undefined; diff --git a/src/common/databases/migrations/20240313090933_whitelist_by_idnot_id/migration.sql b/src/common/databases/migrations/20240313090933_whitelist_by_idnot_id/migration.sql new file mode 100644 index 00000000..5581a06e --- /dev/null +++ b/src/common/databases/migrations/20240313090933_whitelist_by_idnot_id/migration.sql @@ -0,0 +1,16 @@ +-- CreateTable +CREATE TABLE "user_whitelist" ( + "uid" TEXT NOT NULL, + "idNot" VARCHAR(255) NOT NULL, + "active" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3), + + CONSTRAINT "user_whitelist_pkey" PRIMARY KEY ("uid") +); + +-- CreateIndex +CREATE UNIQUE INDEX "user_whitelist_uid_key" ON "user_whitelist"("uid"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_whitelist_idNot_key" ON "user_whitelist"("idNot"); diff --git a/src/common/databases/migrations/20240325150228_subscriptions/migration.sql b/src/common/databases/migrations/20240325150228_subscriptions/migration.sql new file mode 100644 index 00000000..8319eafb --- /dev/null +++ b/src/common/databases/migrations/20240325150228_subscriptions/migration.sql @@ -0,0 +1,34 @@ +-- CreateTable +CREATE TABLE "subscriptions" ( + "uid" TEXT NOT NULL, + "start_date" TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP, + "end_date" TIMESTAMP(3), + "nb_seats" INTEGER NOT NULL, + "office_uid" VARCHAR(255) NOT NULL, + + CONSTRAINT "subscriptions_pkey" PRIMARY KEY ("uid") +); + +-- CreateTable +CREATE TABLE "seats" ( + "uid" TEXT NOT NULL, + "subscription_uid" VARCHAR(255) NOT NULL, + "user_uid" VARCHAR(255) NOT NULL, + + CONSTRAINT "seats_pkey" PRIMARY KEY ("uid") +); + +-- CreateIndex +CREATE UNIQUE INDEX "subscriptions_uid_key" ON "subscriptions"("uid"); + +-- CreateIndex +CREATE UNIQUE INDEX "seats_uid_key" ON "seats"("uid"); + +-- AddForeignKey +ALTER TABLE "subscriptions" ADD CONSTRAINT "subscriptions_office_uid_fkey" FOREIGN KEY ("office_uid") REFERENCES "offices"("uid") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "seats" ADD CONSTRAINT "seats_subscription_uid_fkey" FOREIGN KEY ("subscription_uid") REFERENCES "subscriptions"("uid") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "seats" ADD CONSTRAINT "seats_user_uid_fkey" FOREIGN KEY ("user_uid") REFERENCES "users"("uid") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/common/databases/migrations/20240327160202_edit_subscriptions/migration.sql b/src/common/databases/migrations/20240327160202_edit_subscriptions/migration.sql new file mode 100644 index 00000000..f460f2f5 --- /dev/null +++ b/src/common/databases/migrations/20240327160202_edit_subscriptions/migration.sql @@ -0,0 +1,12 @@ +/* + Warnings: + + - Added the required column `priceId` to the `subscriptions` table without a default value. This is not possible if the table is not empty. + - Made the column `start_date` on table `subscriptions` required. This step will fail if there are existing NULL values in that column. + - Made the column `end_date` on table `subscriptions` required. This step will fail if there are existing NULL values in that column. + +*/ +-- AlterTable +ALTER TABLE "subscriptions" ADD COLUMN "priceId" VARCHAR(255) NOT NULL, +ALTER COLUMN "start_date" SET NOT NULL, +ALTER COLUMN "end_date" SET NOT NULL; diff --git a/src/common/databases/migrations/20240328104442_subscription_type/migration.sql b/src/common/databases/migrations/20240328104442_subscription_type/migration.sql new file mode 100644 index 00000000..6931a9db --- /dev/null +++ b/src/common/databases/migrations/20240328104442_subscription_type/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - Added the required column `type` to the `subscriptions` table without a default value. This is not possible if the table is not empty. + +*/ +-- CreateEnum +CREATE TYPE "ESubscriptionType" AS ENUM ('STANDARD', 'UNLIMITED'); + +-- AlterTable +ALTER TABLE "subscriptions" ADD COLUMN "type" "ESubscriptionType" NOT NULL; diff --git a/src/common/databases/migrations/20240329094447_stripe_subscription_id/migration.sql b/src/common/databases/migrations/20240329094447_stripe_subscription_id/migration.sql new file mode 100644 index 00000000..02e49af0 --- /dev/null +++ b/src/common/databases/migrations/20240329094447_stripe_subscription_id/migration.sql @@ -0,0 +1,10 @@ +/* + Warnings: + + - You are about to drop the column `priceId` on the `subscriptions` table. All the data in the column will be lost. + - Added the required column `stripe_subscription_id` to the `subscriptions` table without a default value. This is not possible if the table is not empty. + +*/ +-- AlterTable +ALTER TABLE "subscriptions" DROP COLUMN "priceId", +ADD COLUMN "stripe_subscription_id" VARCHAR(255) NOT NULL; diff --git a/src/common/databases/migrations/20240329095902_subscription_status/migration.sql b/src/common/databases/migrations/20240329095902_subscription_status/migration.sql new file mode 100644 index 00000000..8c9d5f83 --- /dev/null +++ b/src/common/databases/migrations/20240329095902_subscription_status/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "ESubscriptionStatus" AS ENUM ('ACTIVE', 'INACTIVE'); + +-- AlterTable +ALTER TABLE "subscriptions" ADD COLUMN "status" "ESubscriptionStatus" NOT NULL DEFAULT 'INACTIVE'; diff --git a/src/common/databases/migrations/20240329135543_subscription_status_default_active/migration.sql b/src/common/databases/migrations/20240329135543_subscription_status_default_active/migration.sql new file mode 100644 index 00000000..e036e137 --- /dev/null +++ b/src/common/databases/migrations/20240329135543_subscription_status_default_active/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "subscriptions" ALTER COLUMN "status" SET DEFAULT 'ACTIVE'; diff --git a/src/common/databases/schema.prisma b/src/common/databases/schema.prisma index 53858e86..06d8c782 100644 --- a/src/common/databases/schema.prisma +++ b/src/common/databases/schema.prisma @@ -68,6 +68,7 @@ model Users { appointment Appointments[] votes Votes[] user_notifications UserNotifications[] + seats Seats[] @@map("users") } @@ -81,6 +82,15 @@ model Whitelist { @@map("whitelist") } +model UserWhitelist { + uid String @id @unique @default(uuid()) + idNot String @unique @db.VarChar(255) + active Boolean @default(true) + created_at DateTime? @default(now()) + updated_at DateTime? @updatedAt + @@map("user_whitelist") +} + model Offices { uid String @id @unique @default(uuid()) idNot String @unique @db.VarChar(255) @@ -99,6 +109,7 @@ model Offices { office_folders OfficeFolders[] document_types DocumentTypes[] office_roles OfficeRoles[] + subscriptions Subscriptions[] @@map("offices") } @@ -366,6 +377,39 @@ model TotpCodes { @@map("totp_codes") } +model Subscriptions { + uid String @id @unique @default(uuid()) + type ESubscriptionType + status ESubscriptionStatus @default(ACTIVE) + stripe_subscription_id String @db.VarChar(255) + start_date DateTime @default(now()) + end_date DateTime + nb_seats Int + office Offices @relation(fields: [office_uid], references: [uid], onDelete: Cascade) + office_uid String @db.VarChar(255) + seats Seats[] + @@map("subscriptions") +} + +model Seats { + uid String @id @unique @default(uuid()) + subscription Subscriptions @relation(fields: [subscription_uid], references: [uid], onDelete: Cascade) + subscription_uid String @db.VarChar(255) + user Users @relation(fields: [user_uid], references: [uid], onDelete: Cascade) + user_uid String @db.VarChar(255) + @@map("seats") +} + +enum ESubscriptionStatus { + ACTIVE + INACTIVE +} + +enum ESubscriptionType { + STANDARD + UNLIMITED +} + enum TotpCodesReasons { LOGIN RESET_PASSWORD diff --git a/src/common/repositories/SeatsRepository.ts b/src/common/repositories/SeatsRepository.ts new file mode 100644 index 00000000..3806fda2 --- /dev/null +++ b/src/common/repositories/SeatsRepository.ts @@ -0,0 +1,67 @@ +import Database from "@Common/databases/database"; +import BaseRepository from "@Repositories/BaseRepository"; +import { Service } from "typedi"; +import { Prisma, Seats } from "@prisma/client"; + +@Service() +export default class SeatsRepository extends BaseRepository { + constructor(private database: Database) { + super(); + } + protected get model() { + return this.database.getClient().seats; + } + protected get instanceDb() { + return this.database.getClient(); + } + + /** + * @description : Find many subscriptions + */ + public async findMany(query: Prisma.SeatsFindManyArgs) { + query.take = Math.min(query.take || this.defaultFetchRows, this.maxFetchRows); + if (!query.include) return this.model.findMany({ ...query }); + return this.model.findMany({ ...query }); + } + + /** + * @description : find unique subscription + */ + public async findOneByUid(uid: string, query?: Prisma.SeatsInclude): Promise { + return this.model.findUnique({ + where: { + uid: uid, + }, + include: query, + }); + } + + /** + * @description : Create a subscription + */ + public async create(userUid: string, subscriptionUid: string): Promise { + + const createArgs: Prisma.SeatsCreateArgs = { + data: { + subscription: { + connect: { + uid: subscriptionUid + }, + }, + user: { + connect: { + uid: userUid + }, + }, + }, + }; + { + + return this.model.create(createArgs); + } + + + } + + +} diff --git a/src/common/repositories/SubscriptionsRepository.ts b/src/common/repositories/SubscriptionsRepository.ts new file mode 100644 index 00000000..a8203886 --- /dev/null +++ b/src/common/repositories/SubscriptionsRepository.ts @@ -0,0 +1,145 @@ +import Database from "@Common/databases/database"; +import BaseRepository from "@Repositories/BaseRepository"; +import { Service } from "typedi"; +import { ESubscriptionStatus, ESubscriptionType, Prisma, Subscriptions } from "@prisma/client"; +import { Subscription } from "le-coffre-resources/dist/Admin"; + +@Service() +export default class SubscriptionsRepository extends BaseRepository { + constructor(private database: Database) { + super(); + } + protected get model() { + return this.database.getClient().subscriptions; + } + protected get instanceDb() { + return this.database.getClient(); + } + + /** + * @description : Find many subscriptions + */ + public async findMany(query: Prisma.SubscriptionsFindManyArgs) { + query.take = Math.min(query.take || this.defaultFetchRows, this.maxFetchRows); + if (!query.include) return this.model.findMany({ ...query }); + return this.model.findMany({ ...query }); + } + + /** + * @description : find unique subscription + */ + public async findOneByUid(uid: string, query?: Prisma.SubscriptionsInclude): Promise { + return this.model.findUnique({ + where: { + uid: uid, + }, + include: query, + }); + } + + /** + * @description : Create a subscription + */ + public async create(subscription: Subscription): Promise { + if(subscription.type === "STANDARD") + { + const createArgs: Prisma.SubscriptionsCreateArgs = { + data: { + start_date: subscription.start_date, + end_date: subscription.end_date, + type: ESubscriptionType.STANDARD, + status: ESubscriptionStatus.ACTIVE, + nb_seats: subscription.nb_seats!, + stripe_subscription_id: subscription.stripe_subscription_id || "", + office: { + connect: { + uid: subscription.office!.uid, + }, + }, + seats: { + create: subscription.seats!.map(seat => ({ + user: { + connect: { + uid: seat.user!.uid, + }, + }, + })), + }, + }, + }; + return this.model.create(createArgs); + } + else + { + const createArgs: Prisma.SubscriptionsCreateArgs = { + data: { + start_date: subscription.start_date, + end_date: subscription.end_date, + type: ESubscriptionType.UNLIMITED, + status: ESubscriptionStatus.ACTIVE, + nb_seats: 0, + stripe_subscription_id: subscription.stripe_subscription_id || "", + office: { + connect: { + uid: subscription.office!.uid, + }, + }, + }, + }; + return this.model.create(createArgs); + } + + } + + /** + * @description : update given subscription + */ + public async update(uid: string, subscription: Subscription): Promise { + + if(subscription.type === "STANDARD") + { + const updateArgs: Prisma.SubscriptionsUpdateArgs = { + where: { + uid: uid, + }, + data: { + end_date: subscription.end_date, + type: ESubscriptionType.STANDARD, + status: subscription.status as ESubscriptionStatus, + nb_seats: subscription.nb_seats!, + seats: { + deleteMany: {}, + create: subscription.seats!.map(seat => ({ + user: { + connect: { + uid: seat.user!.uid, + }, + }, + })), + } + }, + }; + return this.model.update(updateArgs); + } + else + { + const updateArgs: Prisma.SubscriptionsUpdateArgs = { + where: { + uid: uid, + }, + data: { + end_date: subscription.end_date, + type: ESubscriptionType.UNLIMITED, + status: subscription.status as ESubscriptionStatus, + nb_seats: 0, + seats: { + deleteMany: {}, + }, + }, + }; + return this.model.update(updateArgs); + } + } + + +} diff --git a/src/common/repositories/UserWhitelistRepository.ts b/src/common/repositories/UserWhitelistRepository.ts new file mode 100644 index 00000000..eae35819 --- /dev/null +++ b/src/common/repositories/UserWhitelistRepository.ts @@ -0,0 +1,39 @@ +import Database from "@Common/databases/database"; +import BaseRepository from "@Repositories/BaseRepository"; +import { Service } from "typedi"; +import { Prisma } from "prisma/prisma-client"; + +@Service() +export default class UserWhitelistRepository extends BaseRepository { + constructor(private database: Database) { + super(); + } + protected get model() { + return this.database.getClient().userWhitelist; + } + protected get instanceDb() { + return this.database.getClient(); + } + + /** + * @description : Find many whitelist + */ + public async findMany(query: Prisma.UserWhitelistFindManyArgs) { + query.take = Math.min(query.take || this.defaultFetchRows, this.maxFetchRows); + return this.model.findMany(query); + } + + /** + * @description : find unique by email + */ + public async findOneByIdNotId(idNotId: string) { + return this.model.findMany({ + where: { + idNot: idNotId, + }, + }); + } + + + +} diff --git a/src/common/webhooks/stripeWebhooks.ts b/src/common/webhooks/stripeWebhooks.ts new file mode 100644 index 00000000..03ba1363 --- /dev/null +++ b/src/common/webhooks/stripeWebhooks.ts @@ -0,0 +1,66 @@ +import ApiController from "@Common/system/controller-pattern/ApiController"; +import { Controller, Post } from "@ControllerPattern/index"; +import SubscriptionsService from "@Services/admin/SubscriptionsService/SubscriptionsService.ts"; +import StripeService from "@Services/common/StripeService/StripeService"; +import { validateOrReject } from "class-validator"; +import { Request, Response } from "express"; +import { Subscription } from "le-coffre-resources/dist/Admin"; +import { Service } from "typedi"; + +@Controller() +@Service() +export default class StripeWebhooks extends ApiController { + constructor(private stripeService: StripeService, private subscriptionsService: SubscriptionsService) { + super(); + } + + /** + * @description Create a new checkout session + */ + @Post("/api/v1/webhooks/stripe") + protected async post(req: Request, response: Response) { + try { + // const sig = req.headers["stripe-signature"]; + // const endpointSecret = "whsec_c4088876914bc166ff5c39253207f84900820b67f7bba3b2669c0ff392cbc838"; + // const stripe = this.stripeService.getClient(); + // let event: Stripe.Event; + + // if (!sig || !endpointSecret) { + // throw new Error("Signature verification failed"); + // } + // event = stripe.webhooks.constructEvent(req.body, sig, endpointSecret); + const event = req.body; + + switch (event.type) { + case "checkout.session.completed": + if (event.data.object.status !== "complete") break; + console.log(event.data.object); + + + const subscription = JSON.parse(event.data.object.metadata.subscription); + subscription.stripe_subscription_id = event.data.object.subscription; + + const subscriptionInfo = await this.stripeService + .getClient() + .subscriptions.retrieve(subscription.stripe_subscription_id); + + subscription.start_date = new Date(subscriptionInfo.current_period_start * 1000); + subscription.end_date = new Date(subscriptionInfo.current_period_end * 1000); + + const subscriptionEntity = Subscription.hydrate(subscription); + + await validateOrReject(subscriptionEntity, { groups: ["createSubscription"], forbidUnknownValues: false }); + + await this.subscriptionsService.create(subscriptionEntity); + break; + default: + break; + } + + response.json({ received: true }); + } catch (error) { + this.httpInternalError(response, error); + return; + } + } +} diff --git a/src/services/admin/SeatsService/SeatsService.ts b/src/services/admin/SeatsService/SeatsService.ts new file mode 100644 index 00000000..77af21a0 --- /dev/null +++ b/src/services/admin/SeatsService/SeatsService.ts @@ -0,0 +1,37 @@ +import BaseService from "@Services/BaseService"; +import "reflect-metadata"; +import { Service } from "typedi"; +import { Prisma, Seats } from "@prisma/client"; +import SeatsRepository from "@Repositories/SeatsRepository"; + +@Service() +export default class SeatsService extends BaseService { + constructor(private seatsRepository: SeatsRepository) { + super(); + } + + /** + * @description : Get all seats + * @throws {Error} If seats cannot be get + */ + public get(query: Prisma.SeatsFindManyArgs) { + return this.seatsRepository.findMany(query); + } + + /** + * @description : Get a seat by uid + * @throws {Error} If seat is not found + */ + public async getByUid(uid: string, query?: Prisma.SeatsInclude) { + return this.seatsRepository.findOneByUid(uid, query); + } + + /** + * @description : Create a new seat + * @throws {Error} If seat cannot be created + */ + public async create(subscriptionUid: string, userUid: string): Promise { + return this.seatsRepository.create(subscriptionUid, userUid); + } + +} diff --git a/src/services/admin/SubscriptionsService/SubscriptionsService.ts.ts b/src/services/admin/SubscriptionsService/SubscriptionsService.ts.ts new file mode 100644 index 00000000..71e31fd9 --- /dev/null +++ b/src/services/admin/SubscriptionsService/SubscriptionsService.ts.ts @@ -0,0 +1,45 @@ +import BaseService from "@Services/BaseService"; +import "reflect-metadata"; +import { Service } from "typedi"; +import { Prisma, Subscriptions } from "@prisma/client"; +import SubscriptionsRepository from "@Repositories/SubscriptionsRepository"; +import { Subscription } from "le-coffre-resources/dist/Admin"; + +@Service() +export default class SubscriptionsService extends BaseService { + constructor(private subscriptionsRepository: SubscriptionsRepository) { + super(); + } + + /** + * @description : Get all subscriptions + * @throws {Error} If subscriptions cannot be get + */ + public get(query: Prisma.SubscriptionsFindManyArgs) { + return this.subscriptionsRepository.findMany(query); + } + + /** + * @description : Get a subscription by uid + * @throws {Error} If subscription is not found + */ + public async getByUid(uid: string, query?: Prisma.SubscriptionsInclude) { + return this.subscriptionsRepository.findOneByUid(uid, query); + } + + /** + * @description : Create a new subscription + * @throws {Error} If subsctiption cannot be created + */ + public async create(subscriptionEntity: Subscription): Promise { + return this.subscriptionsRepository.create(subscriptionEntity); + } + + /** + * @description : Modify a subscription + * @throws {Error} If subscription cannot be modified + */ + public async update(uid: string, subscriptionEntity: Subscription): Promise { + return this.subscriptionsRepository.update(uid, subscriptionEntity); + } +} diff --git a/src/services/common/StripeService/StripeService.ts b/src/services/common/StripeService/StripeService.ts new file mode 100644 index 00000000..59c261f5 --- /dev/null +++ b/src/services/common/StripeService/StripeService.ts @@ -0,0 +1,36 @@ +import { BackendVariables } from "@Common/config/variables/Variables"; +import { Subscription } from "le-coffre-resources/dist/Admin"; +import Stripe from "stripe"; +import { Service } from "typedi"; + +@Service() +export default class StripeService { + private client: Stripe; + constructor(protected variables: BackendVariables) { + this.client = new Stripe(variables.STRIPE_SECRET_KEY); + } + + public getClient(): Stripe { + return this.client; + } + + public async createCheckoutSession(subscription: Subscription) { + const priceId = subscription.type === "STANDARD" ? this.variables.STRIPE_STANDARD_SUBSCRIPTION_PRICE_ID : this.variables.STRIPE_UNLIMITED_SUBSCRIPTION_PRICE_ID; + return this.client.checkout.sessions.create({ + mode: "subscription", + payment_method_types: ["card", "paypal"], + billing_address_collection: "auto", + line_items: [ + { + price: priceId, + quantity: subscription.nb_seats, + }, + ], + success_url: this.variables.STRIPE_PAYMENT_SUCCESS_URL, + cancel_url: this.variables.STRIPE_PAYMENT_CANCEL_URL, + metadata: { + subscription: JSON.stringify(subscription), + }, + }); + } +} \ No newline at end of file diff --git a/src/services/common/UserWhitelistService/WhitelistService.ts b/src/services/common/UserWhitelistService/WhitelistService.ts new file mode 100644 index 00000000..dd96268f --- /dev/null +++ b/src/services/common/UserWhitelistService/WhitelistService.ts @@ -0,0 +1,14 @@ +import UserWhitelistRepository from "@Repositories/UserWhitelistRepository"; +import BaseService from "@Services/BaseService"; +import { Service } from "typedi"; + +@Service() +export default class UserWhitelistService extends BaseService { + constructor(private userWhitelistRepository: UserWhitelistRepository) { + super(); + } + + public async getByIdNotId(idNotId: string): Promise { + return this.userWhitelistRepository.findOneByIdNotId(idNotId); + } +} diff --git a/src/services/super-admin/UsersService/UsersService.ts b/src/services/super-admin/UsersService/UsersService.ts index 3598e945..0781623c 100644 --- a/src/services/super-admin/UsersService/UsersService.ts +++ b/src/services/super-admin/UsersService/UsersService.ts @@ -2,7 +2,7 @@ import BaseService from "@Services/BaseService"; import "reflect-metadata"; import { Service } from "typedi"; import UsersRepository from "@Repositories/UsersRepository"; -import User from "le-coffre-resources/dist/SuperAdmin"; +import User from "le-coffre-resources/dist/Admin"; import { Prisma, Users } from "@prisma/client"; @Service() @@ -91,4 +91,5 @@ export default class UsersService extends BaseService { return this.userRepository.findManyToCheck(); } + }