From e8c3b97f8f0655a3ca9b20e49063c22de3cc4326 Mon Sep 17 00:00:00 2001 From: Till-JS <101404291+Till-JS@users.noreply.github.com> Date: Fri, 13 Feb 2026 23:29:30 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(auth):=20add=20gift=20codes=20?= =?UTF-8?q?and=20enhanced=20credit=20system?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add gift code creation, redemption, and refund endpoints - Add Stripe payment link generation for credits - Add gifts database schema - Enhance credits controller with new operations --- services/mana-core-auth/drizzle.config.ts | 2 +- services/mana-core-auth/src/app.module.ts | 2 + .../src/credits/credits.controller.ts | 34 + .../src/credits/credits.service.ts | 135 ++++ .../credits/dto/create-payment-link.dto.ts | 18 + .../src/db/schema/credits.schema.ts | 3 + .../src/db/schema/gifts.schema.ts | 183 +++++ .../mana-core-auth/src/db/schema/index.ts | 1 + .../src/gifts/dto/create-gift.dto.ts | 82 +++ .../src/gifts/dto/redeem-gift.dto.ts | 77 ++ .../src/gifts/gifts.controller.ts | 96 +++ .../mana-core-auth/src/gifts/gifts.module.ts | 10 + .../src/gifts/services/gift-code.service.ts | 677 ++++++++++++++++++ .../src/stripe/stripe-webhook.controller.ts | 54 ++ .../src/stripe/stripe.service.ts | 83 +++ 15 files changed, 1456 insertions(+), 1 deletion(-) create mode 100644 services/mana-core-auth/src/credits/dto/create-payment-link.dto.ts create mode 100644 services/mana-core-auth/src/db/schema/gifts.schema.ts create mode 100644 services/mana-core-auth/src/gifts/dto/create-gift.dto.ts create mode 100644 services/mana-core-auth/src/gifts/dto/redeem-gift.dto.ts create mode 100644 services/mana-core-auth/src/gifts/gifts.controller.ts create mode 100644 services/mana-core-auth/src/gifts/gifts.module.ts create mode 100644 services/mana-core-auth/src/gifts/services/gift-code.service.ts diff --git a/services/mana-core-auth/drizzle.config.ts b/services/mana-core-auth/drizzle.config.ts index 2e96a07b2..589ab38d6 100644 --- a/services/mana-core-auth/drizzle.config.ts +++ b/services/mana-core-auth/drizzle.config.ts @@ -2,5 +2,5 @@ import { createDrizzleConfig } from '@manacore/shared-drizzle-config'; export default createDrizzleConfig({ dbName: 'manacore', - schemaFilter: ['auth', 'credits', 'referrals', 'subscriptions', 'public'], + schemaFilter: ['auth', 'credits', 'gifts', 'referrals', 'subscriptions', 'public'], }); diff --git a/services/mana-core-auth/src/app.module.ts b/services/mana-core-auth/src/app.module.ts index c89ff741c..17149ca83 100644 --- a/services/mana-core-auth/src/app.module.ts +++ b/services/mana-core-auth/src/app.module.ts @@ -9,6 +9,7 @@ import { ApiKeysModule } from './api-keys/api-keys.module'; import { AuthModule } from './auth/auth.module'; import { CreditsModule } from './credits/credits.module'; import { FeedbackModule } from './feedback/feedback.module'; +import { GiftsModule } from './gifts/gifts.module'; import { HealthModule } from './health/health.module'; import { ReferralsModule } from './referrals/referrals.module'; import { SettingsModule } from './settings/settings.module'; @@ -43,6 +44,7 @@ import { LoggerModule } from './common/logger'; AuthModule, CreditsModule, FeedbackModule, + GiftsModule, HealthModule, ReferralsModule, SettingsModule, diff --git a/services/mana-core-auth/src/credits/credits.controller.ts b/services/mana-core-auth/src/credits/credits.controller.ts index 085a1c0a2..2658030f3 100644 --- a/services/mana-core-auth/src/credits/credits.controller.ts +++ b/services/mana-core-auth/src/credits/credits.controller.ts @@ -7,6 +7,7 @@ import type { CurrentUserData } from '../common/decorators/current-user.decorato import { UseCreditsDto } from './dto/use-credits.dto'; import { AllocateCreditsDto } from './dto/allocate-credits.dto'; import { PurchaseCreditsDto } from './dto/purchase-credits.dto'; +import { CreatePaymentLinkDto } from './dto/create-payment-link.dto'; @ApiTags('credits') @ApiBearerAuth('JWT-auth') @@ -74,6 +75,39 @@ export class CreditsController { return this.creditsService.getPurchaseStatus(user.userId, purchaseId); } + @Post('payment-link') + @ApiOperation({ summary: 'Create payment link for credit purchase' }) + @ApiResponse({ + status: 201, + description: 'Returns Stripe Checkout URL for payment', + schema: { + properties: { + url: { type: 'string', description: 'Stripe Checkout URL' }, + purchaseId: { type: 'string', description: 'Purchase ID for tracking' }, + expiresAt: { type: 'string', format: 'date-time', description: 'Link expiration time' }, + package: { + type: 'object', + properties: { + name: { type: 'string' }, + credits: { type: 'number' }, + priceEuroCents: { type: 'number' }, + }, + }, + }, + }, + }) + @ApiResponse({ status: 404, description: 'Package not found' }) + async createPaymentLink( + @CurrentUser() user: CurrentUserData, + @Body() dto: CreatePaymentLinkDto + ) { + return this.creditsService.createPaymentLink(user.userId, dto.packageId, { + successUrl: dto.successUrl, + cancelUrl: dto.cancelUrl, + roomId: dto.roomId, + }); + } + // ============================================================================ // ORGANIZATION / B2B ENDPOINTS // ============================================================================ diff --git a/services/mana-core-auth/src/credits/credits.service.ts b/services/mana-core-auth/src/credits/credits.service.ts index ad6b41b5a..3ee3715b3 100644 --- a/services/mana-core-auth/src/credits/credits.service.ts +++ b/services/mana-core-auth/src/credits/credits.service.ts @@ -943,4 +943,139 @@ export class CreditsService { completedAt: purchase.completedAt, }; } + + /** + * Update purchase with PaymentIntent ID + * Called from webhook when checkout.session.completed fires + */ + async updatePurchasePaymentIntent(purchaseId: string, paymentIntentId: string): Promise { + const db = this.getDb(); + + await db + .update(purchases) + .set({ + stripePaymentIntentId: paymentIntentId, + }) + .where(eq(purchases.id, purchaseId)); + } + + // ============================================================================ + // PAYMENT LINK METHODS (for Matrix Bots) + // ============================================================================ + + /** + * Create a Stripe Checkout Session URL for credit purchase + * Used by Matrix bots to allow users to buy credits without leaving chat + */ + async createPaymentLink( + userId: string, + packageId: string, + options?: { + successUrl?: string; + cancelUrl?: string; + roomId?: string; + } + ): Promise<{ + url: string; + purchaseId: string; + expiresAt: Date; + package: { + name: string; + credits: number; + priceEuroCents: number; + }; + }> { + const db = this.getDb(); + + // 1. Get package details + const [pkg] = await db + .select() + .from(packages) + .where(and(eq(packages.id, packageId), eq(packages.active, true))) + .limit(1); + + if (!pkg) { + throw new NotFoundException('Package not found or inactive'); + } + + // 2. Get user email for Stripe customer + const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1); + + if (!user) { + throw new NotFoundException('User not found'); + } + + // 3. Get or create Stripe customer + const stripeCustomerId = await this.stripeService.getOrCreateCustomer(userId, user.email); + + // 4. Create pending purchase record + const [purchase] = await db + .insert(purchases) + .values({ + userId, + packageId, + credits: pkg.credits, + priceEuroCents: pkg.priceEuroCents, + stripeCustomerId, + status: 'pending', + metadata: options?.roomId ? { roomId: options.roomId } : undefined, + }) + .returning(); + + // 5. Build URLs + const baseUrl = this.configService.get('app.baseUrl') || 'https://mana.how'; + const successUrl = options?.successUrl || `${baseUrl}/credits/success?purchase_id=${purchase.id}`; + const cancelUrl = options?.cancelUrl || `${baseUrl}/credits/cancelled`; + + // 6. Create Checkout Session + const session = await this.stripeService.createCheckoutSession({ + customerId: stripeCustomerId, + amountCents: pkg.priceEuroCents, + productName: pkg.name, + credits: pkg.credits, + metadata: { + userId, + packageId, + purchaseId: purchase.id, + roomId: options?.roomId, + }, + successUrl, + cancelUrl, + }); + + // 7. Update purchase with session ID + await db + .update(purchases) + .set({ + stripePaymentIntentId: session.payment_intent as string, + metadata: { + ...((purchase.metadata as Record) || {}), + stripeSessionId: session.id, + }, + }) + .where(eq(purchases.id, purchase.id)); + + // Session expires in 24 hours + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); + + this.logger.log('Payment link created', { + purchaseId: purchase.id, + userId, + packageId, + packageName: pkg.name, + credits: pkg.credits, + sessionId: session.id, + }); + + return { + url: session.url!, + purchaseId: purchase.id, + expiresAt, + package: { + name: pkg.name, + credits: pkg.credits, + priceEuroCents: pkg.priceEuroCents, + }, + }; + } } diff --git a/services/mana-core-auth/src/credits/dto/create-payment-link.dto.ts b/services/mana-core-auth/src/credits/dto/create-payment-link.dto.ts new file mode 100644 index 000000000..64d5284ab --- /dev/null +++ b/services/mana-core-auth/src/credits/dto/create-payment-link.dto.ts @@ -0,0 +1,18 @@ +import { IsUUID, IsOptional, IsUrl, IsString } from 'class-validator'; + +export class CreatePaymentLinkDto { + @IsUUID() + packageId: string; + + @IsOptional() + @IsUrl() + successUrl?: string; + + @IsOptional() + @IsUrl() + cancelUrl?: string; + + @IsOptional() + @IsString() + roomId?: string; // For Matrix bot notification after payment +} diff --git a/services/mana-core-auth/src/db/schema/credits.schema.ts b/services/mana-core-auth/src/db/schema/credits.schema.ts index 6db5bcae4..0d1f643cd 100644 --- a/services/mana-core-auth/src/db/schema/credits.schema.ts +++ b/services/mana-core-auth/src/db/schema/credits.schema.ts @@ -22,6 +22,9 @@ export const transactionTypeEnum = pgEnum('transaction_type', [ 'bonus', 'expiry', 'adjustment', + 'gift_reserve', + 'gift_release', + 'gift_receive', ]); // Transaction status enum diff --git a/services/mana-core-auth/src/db/schema/gifts.schema.ts b/services/mana-core-auth/src/db/schema/gifts.schema.ts new file mode 100644 index 000000000..3aa1b1fe2 --- /dev/null +++ b/services/mana-core-auth/src/db/schema/gifts.schema.ts @@ -0,0 +1,183 @@ +/** + * Gifts Schema + * + * Database schema for user-generated gift codes including: + * - Gift codes (simple, personalized, split, first_come, riddle) + * - Gift redemptions tracking + * - Credit reservations and releases + */ + +import { + pgSchema, + uuid, + text, + timestamp, + integer, + index, + pgEnum, +} from 'drizzle-orm/pg-core'; +import { users } from './auth.schema'; + +export const giftsSchema = pgSchema('gifts'); + +// ============================================ +// ENUMS +// ============================================ + +export const giftCodeTypeEnum = pgEnum('gift_code_type', [ + 'simple', + 'personalized', + 'split', + 'first_come', + 'riddle', +]); + +export const giftCodeStatusEnum = pgEnum('gift_code_status', [ + 'active', + 'depleted', + 'expired', + 'cancelled', + 'refunded', +]); + +export const giftRedemptionStatusEnum = pgEnum('gift_redemption_status', [ + 'success', + 'failed_wrong_answer', + 'failed_wrong_user', + 'failed_depleted', + 'failed_expired', + 'failed_already_claimed', +]); + +// ============================================ +// TABLES +// ============================================ + +/** + * Gift Codes + * + * User-generated codes for gifting credits. + * Supports various modes: simple, personalized, split, first_come, riddle + */ +export const giftCodes = giftsSchema.table( + 'gift_codes', + { + id: uuid('id').primaryKey().defaultRandom(), + code: text('code').notNull().unique(), // 6-char code like "ABC123" + shortUrl: text('short_url'), // mana.how/g/ABC123 + + creatorId: text('creator_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + + // Credit allocation + totalCredits: integer('total_credits').notNull(), // Total reserved credits + creditsPerPortion: integer('credits_per_portion').notNull(), // Credits per redemption + totalPortions: integer('total_portions').notNull().default(1), // Number of portions (1 = simple) + claimedPortions: integer('claimed_portions').notNull().default(0), // Portions redeemed + + // Type and status + type: giftCodeTypeEnum('type').notNull().default('simple'), + status: giftCodeStatusEnum('status').notNull().default('active'), + + // Personalization (for 'personalized' type) + targetEmail: text('target_email'), + targetMatrixId: text('target_matrix_id'), + + // Riddle (for 'riddle' type) + riddleQuestion: text('riddle_question'), + riddleAnswerHash: text('riddle_answer_hash'), // bcrypt hash of answer + + // Message + message: text('message'), + + // Expiration + expiresAt: timestamp('expires_at', { withTimezone: true }), + + // Reference to credit reservation transaction + reservationTransactionId: uuid('reservation_transaction_id'), + + // Timestamps + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + codeLookupIdx: index('gift_codes_code_idx').on(table.code), + creatorIdx: index('gift_codes_creator_idx').on(table.creatorId), + statusIdx: index('gift_codes_status_idx').on(table.status), + expiresAtIdx: index('gift_codes_expires_at_idx').on(table.expiresAt), + }) +); + +/** + * Gift Redemptions + * + * Tracks each redemption attempt and success. + */ +export const giftRedemptions = giftsSchema.table( + 'gift_redemptions', + { + id: uuid('id').primaryKey().defaultRandom(), + giftCodeId: uuid('gift_code_id') + .notNull() + .references(() => giftCodes.id, { onDelete: 'cascade' }), + redeemerUserId: text('redeemer_user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + + // Redemption result + status: giftRedemptionStatusEnum('status').notNull(), + creditsReceived: integer('credits_received').notNull().default(0), + portionNumber: integer('portion_number'), // Which portion was claimed (for split/first_come) + + // Reference to credit transaction + creditTransactionId: uuid('credit_transaction_id'), + + // Source tracking + sourceAppId: text('source_app_id'), // 'matrix-bot', 'web', etc. + + // Timestamp + createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(), + }, + (table) => ({ + giftCodeIdx: index('gift_redemptions_gift_code_idx').on(table.giftCodeId), + redeemerIdx: index('gift_redemptions_redeemer_idx').on(table.redeemerUserId), + statusIdx: index('gift_redemptions_status_idx').on(table.status), + }) +); + +// ============================================ +// TYPE EXPORTS +// ============================================ + +export type GiftCode = typeof giftCodes.$inferSelect; +export type NewGiftCode = typeof giftCodes.$inferInsert; + +export type GiftRedemption = typeof giftRedemptions.$inferSelect; +export type NewGiftRedemption = typeof giftRedemptions.$inferInsert; + +// ============================================ +// CONSTANTS +// ============================================ + +/** + * Characters used for gift code generation (uppercase, no ambiguous chars) + */ +export const GIFT_CODE_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + +/** + * Default gift code length + */ +export const GIFT_CODE_LENGTH = 6; + +/** + * Gift code validation rules + */ +export const GIFT_CODE_RULES = { + minCredits: 1, + maxCredits: 10000, + maxPortions: 100, + maxMessageLength: 500, + maxRiddleQuestionLength: 200, + defaultExpirationDays: 90, +} as const; diff --git a/services/mana-core-auth/src/db/schema/index.ts b/services/mana-core-auth/src/db/schema/index.ts index 1b2674d69..06acb2a97 100644 --- a/services/mana-core-auth/src/db/schema/index.ts +++ b/services/mana-core-auth/src/db/schema/index.ts @@ -2,6 +2,7 @@ export * from './api-keys.schema'; export * from './auth.schema'; export * from './credits.schema'; export * from './feedback.schema'; +export * from './gifts.schema'; export * from './organizations.schema'; export * from './referrals.schema'; export * from './subscriptions.schema'; diff --git a/services/mana-core-auth/src/gifts/dto/create-gift.dto.ts b/services/mana-core-auth/src/gifts/dto/create-gift.dto.ts new file mode 100644 index 000000000..d0fac0fee --- /dev/null +++ b/services/mana-core-auth/src/gifts/dto/create-gift.dto.ts @@ -0,0 +1,82 @@ +import { IsString, IsNumber, IsOptional, IsEmail, Min, Max, MaxLength, IsEnum } from 'class-validator'; + +export type GiftCodeType = 'simple' | 'personalized' | 'split' | 'first_come' | 'riddle'; + +export class CreateGiftDto { + /** + * Total credits to gift + */ + @IsNumber() + @Min(1, { message: 'Minimum 1 credit required' }) + @Max(10000, { message: 'Maximum 10000 credits allowed' }) + credits: number; + + /** + * Gift type + */ + @IsOptional() + @IsEnum(['simple', 'personalized', 'split', 'first_come', 'riddle']) + type?: GiftCodeType; + + /** + * Number of portions (for split/first_come) + * Default: 1 + */ + @IsOptional() + @IsNumber() + @Min(1) + @Max(100) + portions?: number; + + /** + * Target email (for personalized) + */ + @IsOptional() + @IsEmail() + targetEmail?: string; + + /** + * Target Matrix ID (for personalized) + */ + @IsOptional() + @IsString() + targetMatrixId?: string; + + /** + * Riddle question (for riddle type) + */ + @IsOptional() + @IsString() + @MaxLength(200) + riddleQuestion?: string; + + /** + * Riddle answer (will be hashed) + */ + @IsOptional() + @IsString() + @MaxLength(100) + riddleAnswer?: string; + + /** + * Optional message to include + */ + @IsOptional() + @IsString() + @MaxLength(500) + message?: string; + + /** + * Expiration date (ISO string) + */ + @IsOptional() + @IsString() + expiresAt?: string; + + /** + * Source app ID (for tracking) + */ + @IsOptional() + @IsString() + sourceAppId?: string; +} diff --git a/services/mana-core-auth/src/gifts/dto/redeem-gift.dto.ts b/services/mana-core-auth/src/gifts/dto/redeem-gift.dto.ts new file mode 100644 index 000000000..777046cde --- /dev/null +++ b/services/mana-core-auth/src/gifts/dto/redeem-gift.dto.ts @@ -0,0 +1,77 @@ +import { IsString, IsOptional, MaxLength } from 'class-validator'; + +export class RedeemGiftDto { + /** + * Riddle answer (required if gift has a riddle) + */ + @IsOptional() + @IsString() + @MaxLength(100) + answer?: string; + + /** + * Source app ID (for tracking) + */ + @IsOptional() + @IsString() + sourceAppId?: string; +} + +export class GiftCodeInfoResponse { + code: string; + type: string; + status: string; + creditsPerPortion: number; + totalPortions: number; + claimedPortions: number; + remainingPortions: number; + message?: string; + riddleQuestion?: string; + hasRiddle: boolean; + isPersonalized: boolean; + expiresAt?: string; + creatorName?: string; +} + +export class GiftRedeemResponse { + success: boolean; + credits?: number; + message?: string; + error?: string; + newBalance?: number; +} + +export class CreateGiftResponse { + id: string; + code: string; + url: string; + totalCredits: number; + creditsPerPortion: number; + totalPortions: number; + type: string; + expiresAt?: string; +} + +export class GiftListItem { + id: string; + code: string; + url: string; + type: string; + status: string; + totalCredits: number; + creditsPerPortion: number; + totalPortions: number; + claimedPortions: number; + message?: string; + expiresAt?: string; + createdAt: string; +} + +export class ReceivedGiftItem { + id: string; + code: string; + credits: number; + message?: string; + creatorName?: string; + redeemedAt: string; +} diff --git a/services/mana-core-auth/src/gifts/gifts.controller.ts b/services/mana-core-auth/src/gifts/gifts.controller.ts new file mode 100644 index 000000000..45e32bd84 --- /dev/null +++ b/services/mana-core-auth/src/gifts/gifts.controller.ts @@ -0,0 +1,96 @@ +import { + Controller, + Get, + Post, + Delete, + Body, + Param, + UseGuards, + Request, + HttpCode, + HttpStatus, + NotFoundException, +} from '@nestjs/common'; +import { GiftCodeService } from './services/gift-code.service'; +import { CreateGiftDto } from './dto/create-gift.dto'; +import { RedeemGiftDto } from './dto/redeem-gift.dto'; +import { JwtAuthGuard } from '../common/guards/jwt-auth.guard'; + +@Controller('api/v1/gifts') +export class GiftsController { + constructor(private readonly giftCodeService: GiftCodeService) {} + + /** + * Get gift code info (public - no auth required) + * For displaying gift info before redeeming + */ + @Get(':code') + async getGiftInfo(@Param('code') code: string) { + const info = await this.giftCodeService.getGiftCodeInfo(code); + if (!info) { + throw new NotFoundException('Gift code not found'); + } + return info; + } + + /** + * Create a new gift code + */ + @Post() + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.CREATED) + async createGift(@Request() req: any, @Body() dto: CreateGiftDto) { + const userId = req.user.sub; + return this.giftCodeService.createGiftCode(userId, dto); + } + + /** + * Redeem a gift code + */ + @Post(':code/redeem') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + async redeemGift( + @Request() req: any, + @Param('code') code: string, + @Body() dto: RedeemGiftDto + ) { + const userId = req.user.sub; + const userEmail = req.user.email; + // Matrix ID would be passed in the request if coming from a Matrix bot + const userMatrixId = req.headers['x-matrix-user-id']; + + return this.giftCodeService.redeemGiftCode(userId, code, dto, userEmail, userMatrixId); + } + + /** + * List gift codes created by the authenticated user + */ + @Get('me/created') + @UseGuards(JwtAuthGuard) + async listCreatedGifts(@Request() req: any) { + const userId = req.user.sub; + return this.giftCodeService.listCreatedGifts(userId); + } + + /** + * List gifts received by the authenticated user + */ + @Get('me/received') + @UseGuards(JwtAuthGuard) + async listReceivedGifts(@Request() req: any) { + const userId = req.user.sub; + return this.giftCodeService.listReceivedGifts(userId); + } + + /** + * Cancel a gift code and get refund for unclaimed portions + */ + @Delete(':id') + @UseGuards(JwtAuthGuard) + @HttpCode(HttpStatus.OK) + async cancelGift(@Request() req: any, @Param('id') id: string) { + const userId = req.user.sub; + return this.giftCodeService.cancelGiftCode(userId, id); + } +} diff --git a/services/mana-core-auth/src/gifts/gifts.module.ts b/services/mana-core-auth/src/gifts/gifts.module.ts new file mode 100644 index 000000000..fbcdc56f6 --- /dev/null +++ b/services/mana-core-auth/src/gifts/gifts.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { GiftsController } from './gifts.controller'; +import { GiftCodeService } from './services/gift-code.service'; + +@Module({ + controllers: [GiftsController], + providers: [GiftCodeService], + exports: [GiftCodeService], +}) +export class GiftsModule {} diff --git a/services/mana-core-auth/src/gifts/services/gift-code.service.ts b/services/mana-core-auth/src/gifts/services/gift-code.service.ts new file mode 100644 index 000000000..28c6f14af --- /dev/null +++ b/services/mana-core-auth/src/gifts/services/gift-code.service.ts @@ -0,0 +1,677 @@ +import { + Injectable, + BadRequestException, + NotFoundException, + ForbiddenException, + ConflictException, + Logger, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { eq, and, desc, sql } from 'drizzle-orm'; +import * as bcrypt from 'bcrypt'; +import { getDb } from '../../db/connection'; +import { + giftCodes, + giftRedemptions, + GIFT_CODE_CHARS, + GIFT_CODE_LENGTH, + GIFT_CODE_RULES, +} from '../../db/schema/gifts.schema'; +import { balances, transactions, users } from '../../db/schema'; +import { CreateGiftDto, GiftCodeType } from '../dto/create-gift.dto'; +import { + RedeemGiftDto, + GiftCodeInfoResponse, + GiftRedeemResponse, + CreateGiftResponse, + GiftListItem, + ReceivedGiftItem, +} from '../dto/redeem-gift.dto'; + +@Injectable() +export class GiftCodeService { + private readonly logger = new Logger(GiftCodeService.name); + + constructor(private configService: ConfigService) {} + + private getDb() { + const databaseUrl = this.configService.get('database.url'); + return getDb(databaseUrl!); + } + + /** + * Generate a unique 6-character gift code + */ + private async generateUniqueCode(): Promise { + const db = this.getDb(); + let attempts = 0; + const maxAttempts = 10; + + while (attempts < maxAttempts) { + // Generate random code + let code = ''; + for (let i = 0; i < GIFT_CODE_LENGTH; i++) { + code += GIFT_CODE_CHARS[Math.floor(Math.random() * GIFT_CODE_CHARS.length)]; + } + + // Check if code exists + const [existing] = await db + .select({ id: giftCodes.id }) + .from(giftCodes) + .where(eq(giftCodes.code, code)) + .limit(1); + + if (!existing) { + return code; + } + + attempts++; + } + + throw new Error('Failed to generate unique code after max attempts'); + } + + /** + * Create a new gift code + */ + async createGiftCode(userId: string, dto: CreateGiftDto): Promise { + const db = this.getDb(); + + // Validate credits + if (dto.credits < GIFT_CODE_RULES.minCredits) { + throw new BadRequestException(`Minimum ${GIFT_CODE_RULES.minCredits} credits required`); + } + if (dto.credits > GIFT_CODE_RULES.maxCredits) { + throw new BadRequestException(`Maximum ${GIFT_CODE_RULES.maxCredits} credits allowed`); + } + + // Determine gift type and portions + const type: GiftCodeType = dto.type || 'simple'; + const portions = dto.portions || 1; + + if (portions > GIFT_CODE_RULES.maxPortions) { + throw new BadRequestException(`Maximum ${GIFT_CODE_RULES.maxPortions} portions allowed`); + } + + // Calculate credits per portion + const creditsPerPortion = Math.floor(dto.credits / portions); + const totalCredits = creditsPerPortion * portions; + + if (creditsPerPortion < 1) { + throw new BadRequestException('Each portion must have at least 1 credit'); + } + + // Validate riddle if provided + if (type === 'riddle' && (!dto.riddleQuestion || !dto.riddleAnswer)) { + throw new BadRequestException('Riddle type requires both question and answer'); + } + + // Hash riddle answer if provided + let riddleAnswerHash: string | null = null; + if (dto.riddleAnswer) { + riddleAnswerHash = await bcrypt.hash(dto.riddleAnswer.toLowerCase().trim(), 10); + } + + // Calculate expiration + let expiresAt: Date | null = null; + if (dto.expiresAt) { + expiresAt = new Date(dto.expiresAt); + if (expiresAt <= new Date()) { + throw new BadRequestException('Expiration date must be in the future'); + } + } else { + // Default expiration + expiresAt = new Date(); + expiresAt.setDate(expiresAt.getDate() + GIFT_CODE_RULES.defaultExpirationDays); + } + + return await db.transaction(async (tx) => { + // 1. Get user balance with row lock + const [userBalance] = await tx + .select() + .from(balances) + .where(eq(balances.userId, userId)) + .for('update') + .limit(1); + + if (!userBalance) { + throw new NotFoundException('User balance not found'); + } + + const totalAvailable = userBalance.balance + userBalance.freeCreditsRemaining; + if (totalAvailable < totalCredits) { + throw new BadRequestException( + `Insufficient credits. Required: ${totalCredits}, Available: ${totalAvailable}` + ); + } + + // 2. Generate unique code + const code = await this.generateUniqueCode(); + + // 3. Deduct credits from user (reserve them) + const freeCreditsUsed = Math.min(totalCredits, userBalance.freeCreditsRemaining); + const paidCreditsUsed = totalCredits - freeCreditsUsed; + + const newFreeCredits = userBalance.freeCreditsRemaining - freeCreditsUsed; + const newBalance = userBalance.balance - paidCreditsUsed; + + const updateResult = await tx + .update(balances) + .set({ + balance: newBalance, + freeCreditsRemaining: newFreeCredits, + version: userBalance.version + 1, + updatedAt: new Date(), + }) + .where(and(eq(balances.userId, userId), eq(balances.version, userBalance.version))) + .returning(); + + if (updateResult.length === 0) { + throw new ConflictException('Balance was modified. Please retry.'); + } + + // 4. Create reservation transaction + const [reservationTx] = await tx + .insert(transactions) + .values({ + userId, + type: 'gift_reserve', + status: 'completed', + amount: -totalCredits, + balanceBefore: totalAvailable, + balanceAfter: newBalance + newFreeCredits, + appId: dto.sourceAppId || 'gift', + description: `Gift code reservation: ${code}`, + completedAt: new Date(), + }) + .returning(); + + // 5. Create gift code + const baseUrl = this.configService.get('app.baseUrl') || 'https://mana.how'; + const shortUrl = `${baseUrl}/g/${code}`; + + const [giftCode] = await tx + .insert(giftCodes) + .values({ + code, + shortUrl, + creatorId: userId, + totalCredits, + creditsPerPortion, + totalPortions: portions, + claimedPortions: 0, + type, + status: 'active', + targetEmail: dto.targetEmail || null, + targetMatrixId: dto.targetMatrixId || null, + riddleQuestion: dto.riddleQuestion || null, + riddleAnswerHash, + message: dto.message || null, + expiresAt, + reservationTransactionId: reservationTx.id, + }) + .returning(); + + this.logger.log('Gift code created', { + codeId: giftCode.id, + code, + userId, + totalCredits, + type, + }); + + return { + id: giftCode.id, + code: giftCode.code, + url: shortUrl, + totalCredits, + creditsPerPortion, + totalPortions: portions, + type, + expiresAt: expiresAt?.toISOString(), + }; + }); + } + + /** + * Get gift code info (public, for preview before redeeming) + */ + async getGiftCodeInfo(code: string): Promise { + const db = this.getDb(); + + const [giftCode] = await db + .select({ + code: giftCodes.code, + type: giftCodes.type, + status: giftCodes.status, + creditsPerPortion: giftCodes.creditsPerPortion, + totalPortions: giftCodes.totalPortions, + claimedPortions: giftCodes.claimedPortions, + message: giftCodes.message, + riddleQuestion: giftCodes.riddleQuestion, + targetEmail: giftCodes.targetEmail, + targetMatrixId: giftCodes.targetMatrixId, + expiresAt: giftCodes.expiresAt, + creatorId: giftCodes.creatorId, + }) + .from(giftCodes) + .where(eq(giftCodes.code, code.toUpperCase())) + .limit(1); + + if (!giftCode) { + return null; + } + + // Get creator name + const [creator] = await db + .select({ name: users.name }) + .from(users) + .where(eq(users.id, giftCode.creatorId)) + .limit(1); + + return { + code: giftCode.code, + type: giftCode.type, + status: giftCode.status, + creditsPerPortion: giftCode.creditsPerPortion, + totalPortions: giftCode.totalPortions, + claimedPortions: giftCode.claimedPortions, + remainingPortions: giftCode.totalPortions - giftCode.claimedPortions, + message: giftCode.message || undefined, + riddleQuestion: giftCode.riddleQuestion || undefined, + hasRiddle: !!giftCode.riddleQuestion, + isPersonalized: !!(giftCode.targetEmail || giftCode.targetMatrixId), + expiresAt: giftCode.expiresAt?.toISOString(), + creatorName: creator?.name || undefined, + }; + } + + /** + * Redeem a gift code + */ + async redeemGiftCode( + userId: string, + code: string, + dto: RedeemGiftDto, + userEmail?: string, + userMatrixId?: string + ): Promise { + const db = this.getDb(); + + return await db.transaction(async (tx) => { + // 1. Get gift code with row lock + const [giftCode] = await tx + .select() + .from(giftCodes) + .where(eq(giftCodes.code, code.toUpperCase())) + .for('update') + .limit(1); + + if (!giftCode) { + return { success: false, error: 'Gift code not found' }; + } + + // 2. Check status + if (giftCode.status !== 'active') { + const statusMessages: Record = { + depleted: 'This gift code has been fully claimed', + expired: 'This gift code has expired', + cancelled: 'This gift code has been cancelled', + refunded: 'This gift code has been refunded', + }; + return { success: false, error: statusMessages[giftCode.status] || 'Gift code is not active' }; + } + + // 3. Check expiration + if (giftCode.expiresAt && giftCode.expiresAt < new Date()) { + // Update status to expired + await tx + .update(giftCodes) + .set({ status: 'expired', updatedAt: new Date() }) + .where(eq(giftCodes.id, giftCode.id)); + + return { success: false, error: 'This gift code has expired' }; + } + + // 4. Check if depleted + if (giftCode.claimedPortions >= giftCode.totalPortions) { + await tx + .update(giftCodes) + .set({ status: 'depleted', updatedAt: new Date() }) + .where(eq(giftCodes.id, giftCode.id)); + + return { success: false, error: 'This gift code has been fully claimed' }; + } + + // 5. Check personalization + if (giftCode.targetEmail || giftCode.targetMatrixId) { + const emailMatch = giftCode.targetEmail && userEmail?.toLowerCase() === giftCode.targetEmail.toLowerCase(); + const matrixMatch = giftCode.targetMatrixId && userMatrixId === giftCode.targetMatrixId; + + if (!emailMatch && !matrixMatch) { + // Record failed attempt + await tx.insert(giftRedemptions).values({ + giftCodeId: giftCode.id, + redeemerUserId: userId, + status: 'failed_wrong_user', + creditsReceived: 0, + sourceAppId: dto.sourceAppId, + }); + + return { success: false, error: 'This gift code is for a specific person' }; + } + } + + // 6. Check riddle answer + if (giftCode.riddleAnswerHash) { + if (!dto.answer) { + return { success: false, error: 'Please provide the answer to the riddle' }; + } + + const isCorrect = await bcrypt.compare(dto.answer.toLowerCase().trim(), giftCode.riddleAnswerHash); + if (!isCorrect) { + // Record failed attempt + await tx.insert(giftRedemptions).values({ + giftCodeId: giftCode.id, + redeemerUserId: userId, + status: 'failed_wrong_answer', + creditsReceived: 0, + sourceAppId: dto.sourceAppId, + }); + + return { success: false, error: 'Incorrect answer' }; + } + } + + // 7. Check if user already claimed (for simple/personalized types) + if (giftCode.type === 'simple' || giftCode.type === 'personalized' || giftCode.type === 'riddle') { + const [existingClaim] = await tx + .select({ id: giftRedemptions.id }) + .from(giftRedemptions) + .where( + and( + eq(giftRedemptions.giftCodeId, giftCode.id), + eq(giftRedemptions.redeemerUserId, userId), + eq(giftRedemptions.status, 'success') + ) + ) + .limit(1); + + if (existingClaim) { + return { success: false, error: 'You have already claimed this gift' }; + } + } + + // 8. Get or create redeemer balance + let [redeemerBalance] = await tx + .select() + .from(balances) + .where(eq(balances.userId, userId)) + .for('update') + .limit(1); + + if (!redeemerBalance) { + // Initialize balance + [redeemerBalance] = await tx + .insert(balances) + .values({ + userId, + balance: 0, + freeCreditsRemaining: 150, // Signup bonus + dailyFreeCredits: 5, + lastDailyResetAt: new Date(), + }) + .returning(); + } + + // 9. Add credits to redeemer + const creditsToAdd = giftCode.creditsPerPortion; + const newBalance = redeemerBalance.balance + creditsToAdd; + const portionNumber = giftCode.claimedPortions + 1; + + await tx + .update(balances) + .set({ + balance: newBalance, + totalEarned: redeemerBalance.totalEarned + creditsToAdd, + version: redeemerBalance.version + 1, + updatedAt: new Date(), + }) + .where(eq(balances.userId, userId)); + + // 10. Create credit transaction for receiver + const [creditTx] = await tx + .insert(transactions) + .values({ + userId, + type: 'gift_receive', + status: 'completed', + amount: creditsToAdd, + balanceBefore: redeemerBalance.balance, + balanceAfter: newBalance, + appId: dto.sourceAppId || 'gift', + description: `Gift received: ${giftCode.code}`, + metadata: { giftCodeId: giftCode.id, portionNumber }, + completedAt: new Date(), + }) + .returning(); + + // 11. Update gift code + const newClaimedPortions = giftCode.claimedPortions + 1; + const newStatus = newClaimedPortions >= giftCode.totalPortions ? 'depleted' : 'active'; + + await tx + .update(giftCodes) + .set({ + claimedPortions: newClaimedPortions, + status: newStatus, + updatedAt: new Date(), + }) + .where(eq(giftCodes.id, giftCode.id)); + + // 12. Record successful redemption + await tx.insert(giftRedemptions).values({ + giftCodeId: giftCode.id, + redeemerUserId: userId, + status: 'success', + creditsReceived: creditsToAdd, + portionNumber, + creditTransactionId: creditTx.id, + sourceAppId: dto.sourceAppId, + }); + + this.logger.log('Gift code redeemed', { + code: giftCode.code, + redeemerUserId: userId, + credits: creditsToAdd, + portionNumber, + }); + + return { + success: true, + credits: creditsToAdd, + message: giftCode.message || undefined, + newBalance, + }; + }); + } + + /** + * Cancel a gift code and refund remaining credits + */ + async cancelGiftCode(userId: string, codeId: string): Promise<{ refundedCredits: number }> { + const db = this.getDb(); + + return await db.transaction(async (tx) => { + // 1. Get gift code with row lock + const [giftCode] = await tx + .select() + .from(giftCodes) + .where(and(eq(giftCodes.id, codeId), eq(giftCodes.creatorId, userId))) + .for('update') + .limit(1); + + if (!giftCode) { + throw new NotFoundException('Gift code not found'); + } + + if (giftCode.status !== 'active') { + throw new BadRequestException('Gift code cannot be cancelled in current status'); + } + + // 2. Calculate refund + const unclaimedPortions = giftCode.totalPortions - giftCode.claimedPortions; + const refundAmount = unclaimedPortions * giftCode.creditsPerPortion; + + if (refundAmount > 0) { + // 3. Get creator balance + const [creatorBalance] = await tx + .select() + .from(balances) + .where(eq(balances.userId, userId)) + .for('update') + .limit(1); + + if (!creatorBalance) { + throw new NotFoundException('Creator balance not found'); + } + + // 4. Refund credits + const newBalance = creatorBalance.balance + refundAmount; + + await tx + .update(balances) + .set({ + balance: newBalance, + totalEarned: creatorBalance.totalEarned + refundAmount, + version: creatorBalance.version + 1, + updatedAt: new Date(), + }) + .where(eq(balances.userId, userId)); + + // 5. Create refund transaction + await tx.insert(transactions).values({ + userId, + type: 'gift_release', + status: 'completed', + amount: refundAmount, + balanceBefore: creatorBalance.balance, + balanceAfter: newBalance, + appId: 'gift', + description: `Gift code cancelled: ${giftCode.code}`, + metadata: { giftCodeId: giftCode.id }, + completedAt: new Date(), + }); + } + + // 6. Update gift code status + const newStatus = giftCode.claimedPortions > 0 ? 'cancelled' : 'refunded'; + + await tx + .update(giftCodes) + .set({ + status: newStatus, + updatedAt: new Date(), + }) + .where(eq(giftCodes.id, giftCode.id)); + + this.logger.log('Gift code cancelled', { + codeId: giftCode.id, + code: giftCode.code, + refundedCredits: refundAmount, + }); + + return { refundedCredits: refundAmount }; + }); + } + + /** + * List gift codes created by a user + */ + async listCreatedGifts(userId: string): Promise { + const db = this.getDb(); + + const codes = await db + .select({ + id: giftCodes.id, + code: giftCodes.code, + shortUrl: giftCodes.shortUrl, + type: giftCodes.type, + status: giftCodes.status, + totalCredits: giftCodes.totalCredits, + creditsPerPortion: giftCodes.creditsPerPortion, + totalPortions: giftCodes.totalPortions, + claimedPortions: giftCodes.claimedPortions, + message: giftCodes.message, + expiresAt: giftCodes.expiresAt, + createdAt: giftCodes.createdAt, + }) + .from(giftCodes) + .where(eq(giftCodes.creatorId, userId)) + .orderBy(desc(giftCodes.createdAt)) + .limit(50); + + return codes.map((code) => ({ + id: code.id, + code: code.code, + url: code.shortUrl || `https://mana.how/g/${code.code}`, + type: code.type, + status: code.status, + totalCredits: code.totalCredits, + creditsPerPortion: code.creditsPerPortion, + totalPortions: code.totalPortions, + claimedPortions: code.claimedPortions, + message: code.message || undefined, + expiresAt: code.expiresAt?.toISOString(), + createdAt: code.createdAt.toISOString(), + })); + } + + /** + * List gifts received by a user + */ + async listReceivedGifts(userId: string): Promise { + const db = this.getDb(); + + const redemptions = await db + .select({ + id: giftRedemptions.id, + code: giftCodes.code, + credits: giftRedemptions.creditsReceived, + message: giftCodes.message, + creatorId: giftCodes.creatorId, + redeemedAt: giftRedemptions.createdAt, + }) + .from(giftRedemptions) + .innerJoin(giftCodes, eq(giftRedemptions.giftCodeId, giftCodes.id)) + .where( + and( + eq(giftRedemptions.redeemerUserId, userId), + eq(giftRedemptions.status, 'success') + ) + ) + .orderBy(desc(giftRedemptions.createdAt)) + .limit(50); + + // Get creator names + const creatorIds = [...new Set(redemptions.map((r) => r.creatorId))]; + const creators = + creatorIds.length > 0 + ? await db + .select({ id: users.id, name: users.name }) + .from(users) + .where(sql`${users.id} = ANY(${creatorIds})`) + : []; + + const creatorMap = new Map(creators.map((c) => [c.id, c.name])); + + return redemptions.map((r) => ({ + id: r.id, + code: r.code, + credits: r.credits, + message: r.message || undefined, + creatorName: creatorMap.get(r.creatorId) || undefined, + redeemedAt: r.redeemedAt.toISOString(), + })); + } +} diff --git a/services/mana-core-auth/src/stripe/stripe-webhook.controller.ts b/services/mana-core-auth/src/stripe/stripe-webhook.controller.ts index 9704c17da..dc071cb95 100644 --- a/services/mana-core-auth/src/stripe/stripe-webhook.controller.ts +++ b/services/mana-core-auth/src/stripe/stripe-webhook.controller.ts @@ -70,6 +70,11 @@ export class StripeWebhookController { // Handle relevant events switch (event.type) { + // Credit purchases via Checkout Session + case 'checkout.session.completed': + await this.handleCheckoutSessionCompleted(event.data.object as Stripe.Checkout.Session); + break; + // Credit purchases case 'payment_intent.succeeded': await this.handlePaymentSucceeded(event.data.object as Stripe.PaymentIntent); @@ -149,6 +154,55 @@ export class StripeWebhookController { } } + private async handleCheckoutSessionCompleted(session: Stripe.Checkout.Session) { + this.logger.log('Processing checkout session completed', { + sessionId: session.id, + paymentIntentId: session.payment_intent, + purchaseId: session.metadata?.purchaseId, + }); + + // For Checkout Sessions, we need to update the purchase with the PaymentIntent ID + // so that the payment_intent.succeeded handler can process it + const purchaseId = session.metadata?.purchaseId; + const paymentIntentId = session.payment_intent as string; + + if (purchaseId && paymentIntentId) { + try { + await this.creditsService.updatePurchasePaymentIntent(purchaseId, paymentIntentId); + this.logger.log('Updated purchase with PaymentIntent ID', { + purchaseId, + paymentIntentId, + }); + } catch (error) { + this.logger.error('Failed to update purchase with PaymentIntent ID', { + purchaseId, + paymentIntentId, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + + // If payment_status is 'paid', complete the purchase immediately + if (session.payment_status === 'paid' && paymentIntentId) { + try { + const result = await this.creditsService.completePurchase(paymentIntentId); + if (result.alreadyProcessed) { + this.logger.log('Purchase already processed', { sessionId: session.id }); + } else { + this.logger.log('Purchase completed via checkout session', { + sessionId: session.id, + creditsAdded: result.creditsAdded, + }); + } + } catch (error) { + this.logger.error('Failed to complete purchase from checkout session', { + sessionId: session.id, + error: error instanceof Error ? error.message : 'Unknown error', + }); + } + } + } + private async handleSubscriptionUpdated(subscription: Stripe.Subscription) { this.logger.log('Processing subscription update', { subscriptionId: subscription.id, diff --git a/services/mana-core-auth/src/stripe/stripe.service.ts b/services/mana-core-auth/src/stripe/stripe.service.ts index e8d5cca27..24fdf312a 100644 --- a/services/mana-core-auth/src/stripe/stripe.service.ts +++ b/services/mana-core-auth/src/stripe/stripe.service.ts @@ -11,6 +11,23 @@ export interface PaymentIntentMetadata { purchaseId: string; } +export interface CheckoutSessionMetadata { + userId: string; + packageId: string; + purchaseId: string; + roomId?: string; +} + +export interface CheckoutSessionOptions { + customerId: string; + amountCents: number; + productName: string; + credits: number; + metadata: CheckoutSessionMetadata; + successUrl: string; + cancelUrl: string; +} + @Injectable() export class StripeService { private stripe: Stripe | null = null; @@ -156,4 +173,70 @@ export class StripeService { const stripe = this.ensureStripeConfigured(); return stripe.paymentIntents.retrieve(paymentIntentId); } + + /** + * Create a Checkout Session for credit purchase + * Returns a URL where the user can complete payment + */ + async createCheckoutSession(options: CheckoutSessionOptions): Promise { + const stripe = this.ensureStripeConfigured(); + + try { + const session = await stripe.checkout.sessions.create({ + customer: options.customerId, + mode: 'payment', + payment_method_types: ['card'], + line_items: [ + { + price_data: { + currency: 'eur', + product_data: { + name: options.productName, + description: `${options.credits} Credits`, + }, + unit_amount: options.amountCents, + }, + quantity: 1, + }, + ], + metadata: { + userId: options.metadata.userId, + packageId: options.metadata.packageId, + purchaseId: options.metadata.purchaseId, + roomId: options.metadata.roomId || '', + }, + success_url: options.successUrl, + cancel_url: options.cancelUrl, + expires_at: Math.floor(Date.now() / 1000) + 24 * 60 * 60, // 24 hours + }); + + this.logger.log('Created Checkout Session', { + sessionId: session.id, + amount: options.amountCents, + customerId: options.customerId, + purchaseId: options.metadata.purchaseId, + }); + + return session; + } catch (error) { + this.logger.error('Failed to create Checkout Session', { + customerId: options.customerId, + amount: options.amountCents, + error: error instanceof Error ? error.message : 'Unknown error', + }); + + if (error instanceof Stripe.errors.StripeError) { + throw new ServiceUnavailableException(`Payment service error: ${error.message}`); + } + throw new ServiceUnavailableException('Failed to create checkout session'); + } + } + + /** + * Retrieve a Checkout Session by ID + */ + async retrieveCheckoutSession(sessionId: string): Promise { + const stripe = this.ensureStripeConfigured(); + return stripe.checkout.sessions.retrieve(sessionId); + } }