diff --git a/CHANGELOG.md b/CHANGELOG.md index 1995cebd9..a731397a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Randomized the placeholder in the assistant - Filtered out sectors with zero weight for ETF and mutual fund assets in the _Yahoo Finance_ data enhancer - Enabled the _Bull Dashboard_ in the admin control panel without requiring an environment variable (experimental) +- Improved the verification of the _Stripe_ checkout session when creating a subscription - Relaxed the URL validation in the asset profile DTOs to accept both `HTTP` and `HTTPS` protocols - Relaxed the URL validation in the platform DTOs to accept both `HTTP` and `HTTPS` protocols - Extracted the page tabs to a reusable component diff --git a/apps/api/src/app/subscription/subscription.controller.ts b/apps/api/src/app/subscription/subscription.controller.ts index e1c705fdd..3e6316ec6 100644 --- a/apps/api/src/app/subscription/subscription.controller.ts +++ b/apps/api/src/app/subscription/subscription.controller.ts @@ -100,10 +100,12 @@ export class SubscriptionController { request.query.checkoutSessionId as string ); - Logger.log( - `Subscription for user '${userId}' has been created via Stripe`, - 'SubscriptionController' - ); + if (userId) { + Logger.log( + `Subscription for user '${userId}' has been created via Stripe`, + 'SubscriptionController' + ); + } response.redirect( `${this.configurationService.get( diff --git a/apps/api/src/app/subscription/subscription.service.ts b/apps/api/src/app/subscription/subscription.service.ts index 07795d0d1..557d81976 100644 --- a/apps/api/src/app/subscription/subscription.service.ts +++ b/apps/api/src/app/subscription/subscription.service.ts @@ -17,7 +17,7 @@ import { } from '@ghostfolio/common/types'; import { Injectable, Logger } from '@nestjs/common'; -import { Subscription } from '@prisma/client'; +import { Prisma, Subscription } from '@prisma/client'; import { addMilliseconds, isBefore } from 'date-fns'; import ms, { StringValue } from 'ms'; import Stripe from 'stripe'; @@ -108,11 +108,13 @@ export class SubscriptionService { duration = '1 year', durationExtension, price, + stripeCheckoutSessionId, userId }: { duration?: StringValue; durationExtension?: StringValue; price: number; + stripeCheckoutSessionId?: string; userId: string; }) { let expiresAt = addMilliseconds(new Date(), ms(duration)); @@ -125,6 +127,7 @@ export class SubscriptionService { data: { expiresAt, price, + stripeCheckoutSessionId, user: { connect: { id: userId @@ -136,25 +139,42 @@ export class SubscriptionService { public async createSubscriptionViaStripe(aCheckoutSessionId: string) { try { - let durationExtension: StringValue; - const session = await this.stripe.checkout.sessions.retrieve(aCheckoutSessionId); + if (session.payment_status !== 'paid' || session.status !== 'complete') { + throw new Error( + `Stripe Checkout Session '${aCheckoutSessionId}' has not been paid (status=${session.status}, payment_status=${session.payment_status})` + ); + } + const subscriptionOffer: SubscriptionOffer = JSON.parse( session.metadata.subscriptionOffer ?? '{}' ); - if (subscriptionOffer) { - durationExtension = subscriptionOffer.durationExtension; + const durationExtension = subscriptionOffer?.durationExtension; + + try { + await this.createSubscription({ + durationExtension, + price: session.amount_total / 100, + stripeCheckoutSessionId: session.id, + userId: session.client_reference_id + }); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + Logger.log( + `Stripe Checkout Session '${session.id}' has already been redeemed`, + 'SubscriptionService' + ); + } else { + throw error; + } } - await this.createSubscription({ - durationExtension, - price: session.amount_total / 100, - userId: session.client_reference_id - }); - return session.client_reference_id; } catch (error) { Logger.error(error, 'SubscriptionService'); diff --git a/prisma/migrations/20260516074856_added_stripe_checkout_session_to_subscription/migration.sql b/prisma/migrations/20260516074856_added_stripe_checkout_session_to_subscription/migration.sql new file mode 100644 index 000000000..f5c8ae016 --- /dev/null +++ b/prisma/migrations/20260516074856_added_stripe_checkout_session_to_subscription/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "Subscription" ADD COLUMN "stripeCheckoutSessionId" TEXT; + +-- CreateIndex +CREATE UNIQUE INDEX "Subscription_stripeCheckoutSessionId_key" ON "Subscription"("stripeCheckoutSessionId"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index a739ce8ff..024ab7aa0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -236,13 +236,14 @@ model SymbolProfileOverrides { } model Subscription { - createdAt DateTime @default(now()) - expiresAt DateTime - id String @id @default(uuid()) - price Float? - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], onDelete: Cascade, references: [id]) - userId String + createdAt DateTime @default(now()) + expiresAt DateTime + id String @id @default(uuid()) + price Float? + stripeCheckoutSessionId String? @unique + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], onDelete: Cascade, references: [id]) + userId String @@index([userId]) }