feat(auth): add gift codes and enhanced credit system

- 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
This commit is contained in:
Till-JS 2026-02-13 23:29:30 +01:00
parent 962b942e2a
commit e8c3b97f8f
15 changed files with 1456 additions and 1 deletions

View file

@ -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'],
});

View file

@ -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,

View file

@ -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
// ============================================================================

View file

@ -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<void> {
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<string>('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<string, unknown>) || {}),
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,
},
};
}
}

View file

@ -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
}

View file

@ -22,6 +22,9 @@ export const transactionTypeEnum = pgEnum('transaction_type', [
'bonus',
'expiry',
'adjustment',
'gift_reserve',
'gift_release',
'gift_receive',
]);
// Transaction status enum

View file

@ -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;

View file

@ -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';

View file

@ -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;
}

View file

@ -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;
}

View file

@ -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);
}
}

View file

@ -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 {}

View file

@ -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<string>('database.url');
return getDb(databaseUrl!);
}
/**
* Generate a unique 6-character gift code
*/
private async generateUniqueCode(): Promise<string> {
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<CreateGiftResponse> {
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<string>('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<GiftCodeInfoResponse | null> {
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<GiftRedeemResponse> {
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<string, string> = {
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<GiftListItem[]> {
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<ReceivedGiftItem[]> {
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(),
}));
}
}

View file

@ -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,

View file

@ -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<Stripe.Checkout.Session> {
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<Stripe.Checkout.Session> {
const stripe = this.ensureStripeConfigured();
return stripe.checkout.sessions.retrieve(sessionId);
}
}