From 10348884c2596e2942337e9d7a79b1f2bd9b7b30 Mon Sep 17 00:00:00 2001 From: Vins Date: Thu, 4 Apr 2024 10:35:53 +0200 Subject: [PATCH] merged dev --- package.json | 3 +- src/app/api/admin/StripeController.ts | 35 ++++++++++ src/app/api/admin/SubscriptionsController.ts | 5 +- src/app/index.ts | 4 ++ src/common/config/variables/Variables.ts | 28 ++++++-- .../migration.sql | 10 +++ .../migration.sql | 5 ++ .../migration.sql | 2 + src/common/databases/schema.prisma | 8 ++- .../repositories/SubscriptionsRepository.ts | 10 ++- src/common/webhooks/stripeWebhooks.ts | 64 +++++++++++++++++++ .../common/StripeService/StripeService.ts | 36 +++++++++++ 12 files changed, 198 insertions(+), 12 deletions(-) create mode 100644 src/app/api/admin/StripeController.ts create mode 100644 src/common/databases/migrations/20240329094447_stripe_subscription_id/migration.sql create mode 100644 src/common/databases/migrations/20240329095902_subscription_status/migration.sql create mode 100644 src/common/databases/migrations/20240329135543_subscription_status_default_active/migration.sql create mode 100644 src/common/webhooks/stripeWebhooks.ts create mode 100644 src/services/common/StripeService/StripeService.ts diff --git a/package.json b/package.json index f807f38f..c1e7881f 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.123", + "le-coffre-resources": "git@github.com:smart-chain-fr/leCoffre-resources.git#v1.125", "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..f411bda3 --- /dev/null +++ b/src/app/api/admin/StripeController.ts @@ -0,0 +1,35 @@ +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", []) + protected async post(req: Request, response: Response) { + try { + //init Subscription resource with request body values + const subscriptionEntity = Subscription.hydrate(req.body); + + 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 index 20baac59..1e3dd0a1 100644 --- a/src/app/api/admin/SubscriptionsController.ts +++ b/src/app/api/admin/SubscriptionsController.ts @@ -86,8 +86,8 @@ export default class SubscriptionsController extends ApiController { try { //init Subscription resource with request body values const subscriptionEntity = Subscription.hydrate(req.body); - //validate user - await validateOrReject(subscriptionEntity, { groups: ["createSubscription"], forbidUnknownValues: false }); + //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 @@ -124,7 +124,6 @@ export default class SubscriptionsController extends ApiController { //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); diff --git a/src/app/index.ts b/src/app/index.ts index 1d9b1acc..174b9abd 100644 --- a/src/app/index.ts +++ b/src/app/index.ts @@ -50,6 +50,8 @@ 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 */ @@ -107,5 +109,7 @@ export default { Container.get(CustomerOfficeRibController); 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 93a426b2..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"]!; @@ -186,10 +201,15 @@ export class BackendVariables { this.OVH_CONSUMER_KEY = process.env["OVH_CONSUMER_KEY"]!; this.OVH_SMS_SERVICE_NAME = process.env["OVH_SMS_SERVICE_NAME"]!; this.SMS_FACTOR_TOKEN = process.env["SMS_FACTOR_TOKEN"]!; - this.SCW_ACCESS_KEY_ID = process.env["ACCESS_KEY_ID"]!; - this.SCW_ACCESS_KEY_SECRET = process.env["ACCESS_KEY_SECRET"]!; - this.SCW_BUCKET_ENDPOINT = process.env["BUCKET_ENDPOINT"]!; - this.SCW_BUCKET_NAME = process.env["BUCKET_NAME"]!; + this.SCW_ACCESS_KEY_ID = process.env["SCW_ACCESS_KEY_ID"]!; + 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/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 d6f48d96..06d8c782 100644 --- a/src/common/databases/schema.prisma +++ b/src/common/databases/schema.prisma @@ -380,7 +380,8 @@ model TotpCodes { model Subscriptions { uid String @id @unique @default(uuid()) type ESubscriptionType - priceId String @db.VarChar(255) + status ESubscriptionStatus @default(ACTIVE) + stripe_subscription_id String @db.VarChar(255) start_date DateTime @default(now()) end_date DateTime nb_seats Int @@ -399,6 +400,11 @@ model Seats { @@map("seats") } +enum ESubscriptionStatus { + ACTIVE + INACTIVE +} + enum ESubscriptionType { STANDARD UNLIMITED diff --git a/src/common/repositories/SubscriptionsRepository.ts b/src/common/repositories/SubscriptionsRepository.ts index 1078deff..a8203886 100644 --- a/src/common/repositories/SubscriptionsRepository.ts +++ b/src/common/repositories/SubscriptionsRepository.ts @@ -1,7 +1,7 @@ import Database from "@Common/databases/database"; import BaseRepository from "@Repositories/BaseRepository"; import { Service } from "typedi"; -import { ESubscriptionType, Prisma, Subscriptions } from "@prisma/client"; +import { ESubscriptionStatus, ESubscriptionType, Prisma, Subscriptions } from "@prisma/client"; import { Subscription } from "le-coffre-resources/dist/Admin"; @Service() @@ -48,8 +48,9 @@ export default class SubscriptionsRepository extends BaseRepository { start_date: subscription.start_date, end_date: subscription.end_date, type: ESubscriptionType.STANDARD, + status: ESubscriptionStatus.ACTIVE, nb_seats: subscription.nb_seats!, - priceId: subscription.priceId, + stripe_subscription_id: subscription.stripe_subscription_id || "", office: { connect: { uid: subscription.office!.uid, @@ -75,8 +76,9 @@ export default class SubscriptionsRepository extends BaseRepository { start_date: subscription.start_date, end_date: subscription.end_date, type: ESubscriptionType.UNLIMITED, + status: ESubscriptionStatus.ACTIVE, nb_seats: 0, - priceId: subscription.priceId, + stripe_subscription_id: subscription.stripe_subscription_id || "", office: { connect: { uid: subscription.office!.uid, @@ -103,6 +105,7 @@ export default class SubscriptionsRepository extends BaseRepository { data: { end_date: subscription.end_date, type: ESubscriptionType.STANDARD, + status: subscription.status as ESubscriptionStatus, nb_seats: subscription.nb_seats!, seats: { deleteMany: {}, @@ -127,6 +130,7 @@ export default class SubscriptionsRepository extends BaseRepository { data: { end_date: subscription.end_date, type: ESubscriptionType.UNLIMITED, + status: subscription.status as ESubscriptionStatus, nb_seats: 0, seats: { deleteMany: {}, diff --git a/src/common/webhooks/stripeWebhooks.ts b/src/common/webhooks/stripeWebhooks.ts new file mode 100644 index 00000000..9463eadc --- /dev/null +++ b/src/common/webhooks/stripeWebhooks.ts @@ -0,0 +1,64 @@ +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; + + 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/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