feat: 支持分享邀请好友功能

This commit is contained in:
richarjiang
2026-04-19 14:12:25 +08:00
parent b02f38dcc7
commit 9575210b06
34 changed files with 1101 additions and 8 deletions

View File

@@ -63,6 +63,12 @@ enum FlashSaleOrderStatus {
EXPIRED
}
enum InviteReferralStatus {
REGISTERED
TRIAL_PURCHASED
QUALIFIED
}
// ===== Models =====
model User {
@@ -82,6 +88,9 @@ model User {
orders Order[]
flashSaleOrders FlashSaleOrder[]
subscriptionMessageConsents SubscriptionMessageConsent[]
sentInviteReferrals InviteReferral[] @relation("InviteReferralInviter")
receivedInviteReferral InviteReferral[] @relation("InviteReferralInvitee")
inviteRewardGrants InviteRewardGrant[] @relation("InviteRewardGrantInviter")
@@map("users")
}
@@ -147,6 +156,7 @@ model Membership {
user User @relation(fields: [userId], references: [id])
cardType CardType @relation(fields: [cardTypeId], references: [id])
bookings Booking[]
inviteRewardGrants InviteRewardGrant[]
@@index([userId])
@@index([status])
@@ -206,6 +216,7 @@ model Booking {
user User @relation(fields: [userId], references: [id])
timeSlot TimeSlot @relation(fields: [timeSlotId], references: [id])
membership Membership @relation(fields: [membershipId], references: [id])
qualifiedInviteReferrals InviteReferral[]
statusHistory BookingStatusHistory[]
@@ -246,12 +257,54 @@ model Order {
user User @relation(fields: [userId], references: [id])
cardType CardType @relation(fields: [cardTypeId], references: [id])
flashSaleOrder FlashSaleOrder?
inviteReferrals InviteReferral[]
@@index([userId])
@@index([status])
@@map("orders")
}
model InviteReferral {
id String @id @default(uuid())
inviterId String @map("inviter_id")
inviteeId String @unique @map("invitee_id")
status InviteReferralStatus @default(REGISTERED)
trialOrderId String? @unique @map("trial_order_id")
qualifiedBookingId String? @unique @map("qualified_booking_id")
invitedAt DateTime @default(now()) @map("invited_at")
trialPurchasedAt DateTime? @map("trial_purchased_at")
qualifiedAt DateTime? @map("qualified_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
inviter User @relation("InviteReferralInviter", fields: [inviterId], references: [id])
invitee User @relation("InviteReferralInvitee", fields: [inviteeId], references: [id])
trialOrder Order? @relation(fields: [trialOrderId], references: [id])
qualifiedBooking Booking? @relation(fields: [qualifiedBookingId], references: [id])
@@unique([inviterId, inviteeId])
@@index([inviterId, status])
@@index([status])
@@map("invite_referrals")
}
model InviteRewardGrant {
id String @id @default(uuid())
inviterId String @map("inviter_id")
membershipId String? @map("membership_id")
qualifiedReferralCount Int @map("qualified_referral_count")
rewardTimes Int @default(1) @map("reward_times")
grantedAt DateTime @default(now()) @map("granted_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
inviter User @relation("InviteRewardGrantInviter", fields: [inviterId], references: [id])
membership Membership? @relation(fields: [membershipId], references: [id])
@@index([inviterId, grantedAt])
@@map("invite_reward_grants")
}
model StudioConfig {
id String @id @default(uuid())
name String

View File

@@ -12,6 +12,7 @@ import { SchedulerModule } from './scheduler/scheduler.module'
import { PaymentModule } from './payment/payment.module'
import { AdminModule } from './admin/admin.module'
import { FlashSaleModule } from './flash-sale/flash-sale.module'
import { InviteModule } from './invite/invite.module'
@Module({
imports: [
@@ -30,6 +31,7 @@ import { FlashSaleModule } from './flash-sale/flash-sale.module'
PaymentModule,
AdminModule,
FlashSaleModule,
InviteModule,
],
controllers: [AppController],
})

View File

@@ -5,6 +5,7 @@ import { UserRole } from '@mp-pilates/shared'
import { AuthService, RANDOM_FN_TOKEN } from '../auth.service'
import { WechatService } from '../wechat.service'
import { PrismaService } from '../../prisma/prisma.service'
import { InviteService } from '../../invite/invite.service'
// ─── Fixtures ────────────────────────────────────────────────────────────────
@@ -46,6 +47,10 @@ const mockJwtService = {
sign: jest.fn(),
}
const mockInviteService = {
bindInviterToUser: jest.fn(),
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('AuthService', () => {
@@ -58,6 +63,7 @@ describe('AuthService', () => {
{ provide: PrismaService, useValue: mockPrismaService },
{ provide: WechatService, useValue: mockWechatService },
{ provide: JwtService, useValue: mockJwtService },
{ provide: InviteService, useValue: mockInviteService },
{ provide: RANDOM_FN_TOKEN, useValue: () => 0 }, // deterministic nickname
],
}).compile()
@@ -91,10 +97,20 @@ describe('AuthService', () => {
where: { openid: OPENID },
})
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
data: { openid: OPENID, nickname: TEST_NICKNAME },
data: { openid: OPENID, nickname: TEST_NICKNAME, adminBookingSubscriptionCount: 0 },
})
expect(result.user).toEqual(mockUser)
expect(result.isNewUser).toBe(true)
expect(mockInviteService.bindInviterToUser).toHaveBeenCalledWith(USER_ID, undefined)
})
it('binds inviter for new users when inviterId is present', async () => {
mockPrismaService.user.findUnique.mockResolvedValue(null)
mockPrismaService.user.create.mockResolvedValue(mockUser)
await authService.login(loginCode, undefined, undefined, 'inviter-001')
expect(mockInviteService.bindInviterToUser).toHaveBeenCalledWith(USER_ID, 'inviter-001')
})
it('creates user with unionid when present', async () => {
@@ -110,7 +126,7 @@ describe('AuthService', () => {
await authService.login(loginCode)
expect(mockPrismaService.user.create).toHaveBeenCalledWith({
data: { openid: OPENID, unionid, nickname: TEST_NICKNAME },
data: { openid: OPENID, unionid, nickname: TEST_NICKNAME, adminBookingSubscriptionCount: 0 },
})
})

View File

@@ -29,6 +29,7 @@ export class AuthController {
loginDto.code,
loginDto.nickname,
loginDto.avatarUrl,
loginDto.inviterId,
)
}

View File

@@ -8,10 +8,12 @@ import { WechatService } from './wechat.service'
import { JwtStrategy } from './jwt.strategy'
import { JwtAuthGuard } from './jwt-auth.guard'
import { RolesGuard } from './roles.guard'
import { InviteModule } from '../invite/invite.module'
@Module({
imports: [
PassportModule,
InviteModule,
JwtModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],

View File

@@ -4,6 +4,7 @@ import { User } from '@prisma/client'
import { UserRole } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { WechatService } from './wechat.service'
import { InviteService } from '../invite/invite.service'
export interface LoginResult {
token: string
@@ -55,6 +56,7 @@ export class AuthService {
private readonly prisma: PrismaService,
private readonly jwtService: JwtService,
private readonly wechatService: WechatService,
private readonly inviteService: InviteService,
@Inject(RANDOM_FN_TOKEN) private readonly randomFn: () => number = Math.random,
) {}
@@ -62,6 +64,7 @@ export class AuthService {
code: string,
nickname?: string,
avatarUrl?: string,
inviterId?: string,
): Promise<LoginResult> {
const { openid, unionid, sessionKey } =
await this.wechatService.code2Session(code)
@@ -98,6 +101,10 @@ export class AuthService {
sessionKeyStore.set(user.id, sessionKey)
if (isNewUser) {
await this.inviteService.bindInviterToUser(user.id, inviterId)
}
const payload: JwtPayload = { sub: user.id, role: user.role as UserRole }
const token = this.jwtService.sign(payload)

View File

@@ -12,4 +12,8 @@ export class LoginDto {
@IsString()
@IsOptional()
avatarUrl?: string
@IsString()
@IsOptional()
inviterId?: string
}

View File

@@ -11,6 +11,7 @@ import { PrismaService } from '../../prisma/prisma.service'
import { MembershipService } from '../../membership/membership.service'
import { StudioService } from '../../studio/studio.service'
import { SubscriptionMessageService } from '../../user/subscription-message.service'
import { InviteService } from '../../invite/invite.service'
// ─── Fixtures ──────────────────────────────────────────────────────────────
@@ -153,6 +154,7 @@ describe('BookingService', () => {
let prisma: jest.Mocked<PrismaService>
let studioService: jest.Mocked<StudioService>
let subscriptionMessageService: { sendBookingConfirmedMessage: jest.Mock; sendAdminBookingCreatedMessage: jest.Mock }
let inviteService: { recordQualifiedTrialBooking: jest.Mock }
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
@@ -204,6 +206,12 @@ describe('BookingService', () => {
sendAdminBookingCreatedMessage: jest.fn(),
},
},
{
provide: InviteService,
useValue: {
recordQualifiedTrialBooking: jest.fn(),
},
},
],
}).compile()
@@ -211,6 +219,7 @@ describe('BookingService', () => {
prisma = module.get(PrismaService) as jest.Mocked<PrismaService>
studioService = module.get(StudioService) as jest.Mocked<StudioService>
subscriptionMessageService = module.get(SubscriptionMessageService)
inviteService = module.get(InviteService)
})
afterEach(() => jest.clearAllMocks())
@@ -262,6 +271,44 @@ describe('BookingService', () => {
})
})
describe('completeBooking', () => {
it('records qualified trial booking after completion', async () => {
const tx = buildTxMock({
bookingStatusHistory: { create: jest.fn() },
})
tx.booking.findUnique.mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.CONFIRMED,
timeSlot: mockOpenSlot,
})
tx.booking.update.mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.COMPLETED,
completedAt: new Date('2099-12-31T11:00:00Z'),
})
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
...mockConfirmedBooking,
status: BookingStatus.COMPLETED,
completedAt: new Date('2099-12-31T11:00:00Z'),
timeSlot: mockOpenSlot,
membership: {
...mockActiveMembership,
cardType: {
...mockTimesCardType,
type: CardTypeCategory.TRIAL,
},
},
})
await service.completeBooking(MOCK_BOOKING_ID, 'admin-001')
expect(inviteService.recordQualifiedTrialBooking).toHaveBeenCalledWith(MOCK_BOOKING_ID)
})
})
// ─── createBooking ────────────────────────────────────────────────────────
describe('createBooking', () => {

View File

@@ -4,9 +4,10 @@ import { BookingService } from './booking.service'
import { MembershipModule } from '../membership/membership.module'
import { StudioModule } from '../studio/studio.module'
import { UserModule } from '../user/user.module'
import { InviteModule } from '../invite/invite.module'
@Module({
imports: [MembershipModule, StudioModule, UserModule],
imports: [MembershipModule, StudioModule, UserModule, InviteModule],
controllers: [BookingController],
providers: [BookingService],
exports: [BookingService],

View File

@@ -12,6 +12,7 @@ import { MembershipService } from '../membership/membership.service'
import { StudioService } from '../studio/studio.service'
import { SubscriptionMessageService } from '../user/subscription-message.service'
import { CreateBookingDto } from './dto/create-booking.dto'
import { InviteService } from '../invite/invite.service'
// ─── Types ─────────────────────────────────────────────────────────────────
@@ -50,6 +51,7 @@ export class BookingService {
private readonly membershipService: MembershipService,
private readonly studioService: StudioService,
private readonly subscriptionMessageService: SubscriptionMessageService,
private readonly inviteService: InviteService,
) {}
// ─── Create Booking ──────────────────────────────────────────────────────
@@ -330,7 +332,11 @@ export class BookingService {
return updated
})
return this.fetchBookingWithRelations(booking.id)
const result = await this.fetchBookingWithRelations(booking.id)
if (toStatus === BookingStatus.COMPLETED) {
await this.inviteService.recordQualifiedTrialBooking(result.id)
}
return result
}
// ─── Cancel Booking ──────────────────────────────────────────────────────

View File

@@ -0,0 +1,3 @@
export const INVITE_REWARD_REQUIRED_COUNT = 3
export const INVITE_REWARD_TIMES = 1

View File

@@ -0,0 +1,16 @@
import { Controller, Get, UseGuards } from '@nestjs/common'
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
import { CurrentUser } from '../common/decorators/current-user.decorator'
import { InviteService } from './invite.service'
@Controller('invite')
@UseGuards(JwtAuthGuard)
export class InviteController {
constructor(private readonly inviteService: InviteService) {}
@Get('activity')
getActivity(@CurrentUser('sub') userId: string) {
return this.inviteService.getInviteActivitySummary(userId)
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common'
import { InviteController } from './invite.controller'
import { InviteService } from './invite.service'
@Module({
controllers: [InviteController],
providers: [InviteService],
exports: [InviteService],
})
export class InviteModule {}

View File

@@ -0,0 +1,253 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common'
import type { InviteReferral, InviteRewardGrant, Membership } from '@prisma/client'
import { InviteReferralStatus, MembershipStatus, OrderStatus } from '@mp-pilates/shared'
import type { InviteActivitySummary } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import {
INVITE_REWARD_REQUIRED_COUNT,
INVITE_REWARD_TIMES,
} from './invite.constants'
@Injectable()
export class InviteService {
constructor(private readonly prisma: PrismaService) {}
private isTrialCardType(type: string): boolean {
return type === 'TRIAL'
}
async bindInviterToUser(inviteeId: string, inviterId?: string | null): Promise<void> {
if (!inviterId || inviterId === inviteeId) {
return
}
const [inviter, inviteeMembershipCount, existingReferral] = await Promise.all([
this.prisma.user.findUnique({ where: { id: inviterId }, select: { id: true } }),
this.prisma.membership.count({ where: { userId: inviteeId } }),
this.prisma.inviteReferral.findUnique({ where: { inviteeId } }),
])
if (!inviter || inviteeMembershipCount > 0 || existingReferral) {
return
}
await this.prisma.inviteReferral.create({
data: {
inviterId,
inviteeId,
},
})
}
async recordTrialOrderPaid(orderId: string): Promise<void> {
const order = await this.prisma.order.findUnique({
where: { id: orderId },
include: {
cardType: true,
user: { select: { id: true } },
},
})
if (!order || order.status !== OrderStatus.PAID || !this.isTrialCardType(order.cardType.type)) {
return
}
await this.prisma.inviteReferral.updateMany({
where: {
inviteeId: order.user.id,
status: InviteReferralStatus.REGISTERED,
trialOrderId: null,
},
data: {
status: InviteReferralStatus.TRIAL_PURCHASED,
trialOrderId: order.id,
trialPurchasedAt: order.paidAt ?? new Date(),
},
})
}
async recordQualifiedTrialBooking(bookingId: string): Promise<void> {
const booking = await this.prisma.booking.findUnique({
where: { id: bookingId },
include: {
membership: { include: { cardType: true } },
},
})
if (!booking || booking.status !== 'COMPLETED' || !this.isTrialCardType(booking.membership.cardType.type)) {
return
}
const referral = await this.prisma.inviteReferral.findFirst({
where: {
inviteeId: booking.userId,
status: {
in: [InviteReferralStatus.REGISTERED, InviteReferralStatus.TRIAL_PURCHASED],
},
qualifiedBookingId: null,
},
orderBy: { createdAt: 'asc' },
})
if (!referral) {
return
}
await this.prisma.inviteReferral.update({
where: { id: referral.id },
data: {
status: InviteReferralStatus.QUALIFIED,
qualifiedBookingId: booking.id,
qualifiedAt: booking.completedAt ?? new Date(),
},
})
await this.grantRewardsIfEligible(referral.inviterId)
}
async getInviteActivitySummary(userId: string): Promise<InviteActivitySummary> {
const memberships = await this.prisma.membership.findMany({
where: { userId },
orderBy: [{ status: 'asc' }, { expireDate: 'desc' }],
})
const referrals = await this.prisma.inviteReferral.findMany({
where: { inviterId: userId },
include: {
invitee: {
select: {
id: true,
nickname: true,
avatarUrl: true,
},
},
},
orderBy: { createdAt: 'desc' },
})
const rewardGrants = await this.prisma.inviteRewardGrant.findMany({
where: { inviterId: userId },
orderBy: { grantedAt: 'desc' },
})
const canInvite = memberships.some((membership: Membership) => membership.status === MembershipStatus.ACTIVE)
const qualifiedInviteCount = referrals.filter((item: InviteReferral) => item.status === InviteReferralStatus.QUALIFIED).length
const rewardedTimes = rewardGrants.reduce((sum: number, item: InviteRewardGrant) => sum + item.rewardTimes, 0)
const pendingRewardGrantCount = Math.max(
0,
qualifiedInviteCount - rewardGrants.length * INVITE_REWARD_REQUIRED_COUNT,
)
const currentCycleQualifiedCount = qualifiedInviteCount % INVITE_REWARD_REQUIRED_COUNT
return {
inviterId: userId,
canInvite,
sharePath: `/pages/profile/invite?inviterId=${userId}`,
rewardRuleInvitesRequired: INVITE_REWARD_REQUIRED_COUNT,
rewardRuleTimes: INVITE_REWARD_TIMES,
qualifiedInviteCount,
rewardedTimes,
pendingRewardGrantCount,
pendingInviteCount: referrals.filter((item: InviteReferral) => item.status !== InviteReferralStatus.QUALIFIED).length,
currentCycleQualifiedCount,
nextRewardRemainingCount: currentCycleQualifiedCount === 0
? INVITE_REWARD_REQUIRED_COUNT
: INVITE_REWARD_REQUIRED_COUNT - currentCycleQualifiedCount,
referrals: referrals.map((item: InviteReferral & { invitee: { nickname: string; avatarUrl: string | null } }) => ({
id: item.id,
inviteeId: item.inviteeId,
inviteeNickname: item.invitee.nickname,
inviteeAvatarUrl: item.invitee.avatarUrl,
status: item.status as InviteReferralStatus,
invitedAt: item.invitedAt.toISOString(),
trialPurchasedAt: item.trialPurchasedAt?.toISOString() ?? null,
qualifiedAt: item.qualifiedAt?.toISOString() ?? null,
})),
rewardGrants: rewardGrants.map((item: InviteRewardGrant) => ({
id: item.id,
membershipId: item.membershipId,
qualifiedReferralCount: item.qualifiedReferralCount,
rewardTimes: item.rewardTimes,
grantedAt: item.grantedAt.toISOString(),
})),
}
}
async validateInviterForTrialOrder(userId: string, inviterId?: string): Promise<void> {
if (!inviterId) {
return
}
if (inviterId === userId) {
throw new BadRequestException('不能邀请自己购买体验课')
}
const referral = await this.prisma.inviteReferral.findFirst({
where: {
inviterId,
inviteeId: userId,
},
})
if (!referral) {
throw new NotFoundException('邀请关系不存在或已失效')
}
}
private async grantRewardsIfEligible(inviterId: string): Promise<void> {
const [qualifiedCount, rewardGrantCount] = await Promise.all([
this.prisma.inviteReferral.count({
where: {
inviterId,
status: InviteReferralStatus.QUALIFIED,
},
}),
this.prisma.inviteRewardGrant.count({ where: { inviterId } }),
])
const shouldGrantCount = Math.floor(qualifiedCount / INVITE_REWARD_REQUIRED_COUNT)
const missingGrantCount = shouldGrantCount - rewardGrantCount
if (missingGrantCount <= 0) {
return
}
for (let index = 0; index < missingGrantCount; index += 1) {
const targetQualifiedCount = (rewardGrantCount + index + 1) * INVITE_REWARD_REQUIRED_COUNT
await this.prisma.$transaction(async (tx) => {
const membership = await tx.membership.findFirst({
where: {
userId: inviterId,
status: MembershipStatus.ACTIVE,
},
orderBy: [{ expireDate: 'desc' }, { createdAt: 'desc' }],
})
if (!membership) {
throw new BadRequestException('邀请人当前没有有效会员卡,无法发放奖励')
}
await tx.membership.update({
where: { id: membership.id },
data: {
remainingTimes: membership.remainingTimes === null
? null
: membership.remainingTimes + INVITE_REWARD_TIMES,
status: MembershipStatus.ACTIVE,
},
})
await tx.inviteRewardGrant.create({
data: {
inviterId,
membershipId: membership.id,
qualifiedReferralCount: targetQualifiedCount,
rewardTimes: INVITE_REWARD_TIMES,
},
})
})
}
}
}

View File

@@ -5,6 +5,7 @@ import { MembershipStatus, OrderStatus } from '@mp-pilates/shared'
import { PaymentService } from '../payment.service'
import { WechatPayService } from '../wechat-pay.service'
import { PrismaService } from '../../prisma/prisma.service'
import { InviteService } from '../../invite/invite.service'
// ─── Fixtures ─────────────────────────────────────────────────────────────────
@@ -35,6 +36,11 @@ const mockUser = {
updatedAt: new Date(),
}
const mockInviteService = {
validateInviterForTrialOrder: jest.fn(),
recordTrialOrderPaid: jest.fn(),
}
const buildMockOrder = (overrides: Partial<Record<string, unknown>> = {}) => ({
id: 'order-uuid-1',
userId: mockUser.id,
@@ -105,6 +111,7 @@ describe('PaymentService', () => {
PaymentService,
{ provide: PrismaService, useValue: prisma },
{ provide: WechatPayService, useValue: wechat },
{ provide: InviteService, useValue: mockInviteService },
],
}).compile()
@@ -161,6 +168,16 @@ describe('PaymentService', () => {
)
})
it('validates inviter relationship for trial card orders', async () => {
prisma.cardType.findUnique.mockResolvedValue({ ...mockCardType, type: 'TRIAL' })
prisma.user.findUnique.mockResolvedValue(mockUser)
prisma.order.create.mockResolvedValue(buildMockOrder())
await service.createOrder(mockUser.id, mockCardType.id, 'inviter-001')
expect(mockInviteService.validateInviterForTrialOrder).toHaveBeenCalledWith(mockUser.id, 'inviter-001')
})
it('throws NotFoundException when cardType does not exist', async () => {
prisma.cardType.findUnique.mockResolvedValue(null)
@@ -232,6 +249,7 @@ describe('PaymentService', () => {
// membership.create was called
expect(prisma.membership.create).toHaveBeenCalledTimes(1)
expect(mockInviteService.recordTrialOrderPaid).toHaveBeenCalledWith(pendingOrder.id)
expect(result).toContain('SUCCESS')
})

View File

@@ -1,6 +1,10 @@
import { IsUUID } from 'class-validator'
import { IsOptional, IsUUID } from 'class-validator'
export class CreateOrderDto {
@IsUUID()
cardTypeId!: string
@IsUUID()
@IsOptional()
inviterId?: string
}

View File

@@ -33,7 +33,7 @@ export class PaymentController {
@CurrentUser('sub') userId: string,
@Body(new ValidationPipe({ whitelist: true })) dto: CreateOrderDto,
) {
return this.paymentService.createOrder(userId, dto.cardTypeId)
return this.paymentService.createOrder(userId, dto.cardTypeId, dto.inviterId)
}
/**

View File

@@ -3,9 +3,10 @@ import { PrismaModule } from '../prisma/prisma.module'
import { PaymentService } from './payment.service'
import { PaymentController } from './payment.controller'
import { WechatPayService } from './wechat-pay.service'
import { InviteModule } from '../invite/invite.module'
@Module({
imports: [PrismaModule],
imports: [PrismaModule, InviteModule],
controllers: [PaymentController],
providers: [PaymentService, WechatPayService],
exports: [PaymentService, WechatPayService],

View File

@@ -8,6 +8,7 @@ import { CardType, Order } from '@prisma/client'
import { MembershipStatus, OrderStatus, FlashSaleOrderStatus } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { WechatPayService, WxPaymentParams } from './wechat-pay.service'
import { InviteService } from '../invite/invite.service'
export interface CreateOrderResult {
order: Order
@@ -28,11 +29,12 @@ export class PaymentService {
constructor(
private readonly prisma: PrismaService,
private readonly wechatPayService: WechatPayService,
private readonly inviteService: InviteService,
) {}
// ─── User: create order ────────────────────────────────────────────────────
async createOrder(userId: string, cardTypeId: string): Promise<CreateOrderResult> {
async createOrder(userId: string, cardTypeId: string, inviterId?: string): Promise<CreateOrderResult> {
const cardType = await this.prisma.cardType.findUnique({ where: { id: cardTypeId } })
if (!cardType) {
@@ -47,6 +49,10 @@ export class PaymentService {
throw new NotFoundException(`User ${userId} not found`)
}
if (cardType.type === 'TRIAL') {
await this.inviteService.validateInviterForTrialOrder(userId, inviterId)
}
const orderNo = `${Date.now()}${Math.random().toString(36).substring(2, 8)}`
const order = await this.prisma.order.create({
@@ -135,6 +141,8 @@ export class PaymentService {
}),
])
await this.inviteService.recordTrialOrderPaid(existingOrder.id)
this.logger.log(`Order PAID and Membership created: orderNo=${notification.orderNo}`)
// ── Flash sale order: mark as PAID ──

View File

@@ -71,6 +71,7 @@ export class UserService {
avatarUrl: user.avatarUrl,
role: user.role as UserRole,
activeMembershipCount: user._count.memberships,
inviteShareEligible: user._count.memberships > 0,
adminBookingSubscriptionCount: user.adminBookingSubscriptionCount,
subscriptionMessageTemplates: this.buildSubscriptionTemplateConfig(),
createdAt: user.createdAt.toISOString(),