mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:41:09 +02:00
✨ 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:
parent
962b942e2a
commit
e8c3b97f8f
15 changed files with 1456 additions and 1 deletions
|
|
@ -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'],
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ============================================================================
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -22,6 +22,9 @@ export const transactionTypeEnum = pgEnum('transaction_type', [
|
|||
'bonus',
|
||||
'expiry',
|
||||
'adjustment',
|
||||
'gift_reserve',
|
||||
'gift_release',
|
||||
'gift_receive',
|
||||
]);
|
||||
|
||||
// Transaction status enum
|
||||
|
|
|
|||
183
services/mana-core-auth/src/db/schema/gifts.schema.ts
Normal file
183
services/mana-core-auth/src/db/schema/gifts.schema.ts
Normal 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;
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
82
services/mana-core-auth/src/gifts/dto/create-gift.dto.ts
Normal file
82
services/mana-core-auth/src/gifts/dto/create-gift.dto.ts
Normal 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;
|
||||
}
|
||||
77
services/mana-core-auth/src/gifts/dto/redeem-gift.dto.ts
Normal file
77
services/mana-core-auth/src/gifts/dto/redeem-gift.dto.ts
Normal 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;
|
||||
}
|
||||
96
services/mana-core-auth/src/gifts/gifts.controller.ts
Normal file
96
services/mana-core-auth/src/gifts/gifts.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
services/mana-core-auth/src/gifts/gifts.module.ts
Normal file
10
services/mana-core-auth/src/gifts/gifts.module.ts
Normal 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 {}
|
||||
677
services/mana-core-auth/src/gifts/services/gift-code.service.ts
Normal file
677
services/mana-core-auth/src/gifts/services/gift-code.service.ts
Normal 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(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue