feat: 支持邀请好友功能
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE `users` ADD COLUMN `invite_code` VARCHAR(6) NULL;
|
||||
CREATE UNIQUE INDEX `users_invite_code_key` ON `users` (`invite_code`);
|
||||
ALTER TABLE `orders` ADD COLUMN `invite_inviter_id` VARCHAR(191) NULL, ADD COLUMN `purchased_category` VARCHAR(191) NULL;
|
||||
INSERT INTO `card_types` (`id`, `name`, `type`, `total_times`, `duration_days`, `price`, `is_active`, `sort_order`, `updated_at`) VALUES ('invite-reward-card', '邀请好友赠课', 'TIMES', 1, 365, 0, false, 9999, NOW());
|
||||
@@ -73,6 +73,7 @@ enum InviteReferralStatus {
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
inviteCode String? @unique @map("invite_code") @db.VarChar(6)
|
||||
openid String @unique
|
||||
unionid String?
|
||||
phone String?
|
||||
@@ -254,6 +255,8 @@ model Order {
|
||||
cardTypeId String @map("card_type_id")
|
||||
membershipId String? @map("membership_id")
|
||||
orderNo String @unique @map("order_no")
|
||||
inviteInviterId String? @map("invite_inviter_id")
|
||||
purchasedCategory String? @map("purchased_category")
|
||||
amount Decimal @db.Decimal(10, 0)
|
||||
status OrderStatus @default(PENDING)
|
||||
wxTransactionId String? @map("wx_transaction_id")
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { FlashSaleService } from '../flash-sale.service'
|
||||
import { PrismaService } from '../../prisma/prisma.service'
|
||||
import { WechatPayService } from '../../payment/wechat-pay.service'
|
||||
|
||||
describe('Flash sale referral pricing', () => {
|
||||
it.each([true, false])('snapshots referral and category with eligible=%s', async (eligible) => {
|
||||
const prisma = {
|
||||
user: { findUnique: jest.fn().mockResolvedValue({ id: 'buyer', phone: '123', openid: 'openid' }) },
|
||||
flashSale: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'sale', title: 'sale', cardTypeId: 'card', cardType: { type: 'TIMES' }, status: 'ACTIVE', startTime: new Date(0), endTime: new Date(Date.now() + 60000), totalStock: 10, flashPrice: 999 }),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
inviteReferral: { findUnique: jest.fn().mockResolvedValue(eligible ? { inviterId: 'friend' } : null) },
|
||||
order: { create: jest.fn().mockImplementation(({ data }) => ({ ...data, id: 'order' })) },
|
||||
flashSaleOrder: { create: jest.fn().mockResolvedValue({ id: 'reserved' }) },
|
||||
$transaction: jest.fn(),
|
||||
}
|
||||
prisma.$transaction.mockImplementation((fn) => fn(prisma))
|
||||
const pay = { createUnifiedOrder: jest.fn().mockResolvedValue({}) }
|
||||
const service = new FlashSaleService(prisma as unknown as PrismaService, pay as unknown as WechatPayService)
|
||||
await service.purchase('sale', 'buyer')
|
||||
expect(prisma.order.create).toHaveBeenCalledWith({ data: expect.objectContaining({ amount: eligible ? 949 : 999, inviteInviterId: eligible ? 'friend' : undefined, purchasedCategory: 'TIMES' }) })
|
||||
expect(pay.createUnifiedOrder).toHaveBeenCalledWith(expect.objectContaining({ amount: eligible ? 949 : 999 }))
|
||||
})
|
||||
})
|
||||
@@ -167,12 +167,15 @@ export class FlashSaleService {
|
||||
// ②-b: Create Order with flash sale price
|
||||
const orderNo = `FS${Date.now()}${Math.random().toString(36).substring(2, 8)}`
|
||||
|
||||
const referral = await tx.inviteReferral.findUnique({ where: { inviteeId: userId } })
|
||||
const order = await tx.order.create({
|
||||
data: {
|
||||
userId,
|
||||
cardTypeId: sale.cardTypeId,
|
||||
orderNo,
|
||||
amount: sale.flashPrice,
|
||||
amount: referral ? Math.round(Number(sale.flashPrice) * 95 / 100) : sale.flashPrice,
|
||||
inviteInviterId: referral?.inviterId,
|
||||
purchasedCategory: sale.cardType.type,
|
||||
status: OrderStatus.PENDING,
|
||||
flashSaleId,
|
||||
},
|
||||
|
||||
71
packages/server/src/invite/__tests__/invite.service.spec.ts
Normal file
71
packages/server/src/invite/__tests__/invite.service.spec.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { InviteService } from '../invite.service'
|
||||
import { PrismaService } from '../../prisma/prisma.service'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
|
||||
describe('InviteService marketing rules', () => {
|
||||
function setup() {
|
||||
const prisma = {
|
||||
user: { findUniqueOrThrow: jest.fn(), updateMany: jest.fn(), findUnique: jest.fn() },
|
||||
inviteReferral: { upsert: jest.fn(), findUnique: jest.fn(), updateMany: jest.fn() },
|
||||
membership: { create: jest.fn() }, inviteRewardGrant: { create: jest.fn() },
|
||||
}
|
||||
return { prisma, service: new InviteService(prisma as unknown as PrismaService) }
|
||||
}
|
||||
it('lazily reuses existing short codes without writing', async () => {
|
||||
const { prisma, service } = setup()
|
||||
prisma.user.findUniqueOrThrow.mockResolvedValue({ inviteCode: 'ABC234' })
|
||||
expect(await service.ensureCode('user')).toBe('ABC234')
|
||||
expect(prisma.user.updateMany).not.toHaveBeenCalled()
|
||||
})
|
||||
it('retries code collisions and only writes if still missing', async () => {
|
||||
const { prisma, service } = setup()
|
||||
prisma.user.findUniqueOrThrow.mockResolvedValue({ inviteCode: null })
|
||||
prisma.user.updateMany.mockRejectedValueOnce({ code: 'P2002' }).mockResolvedValueOnce({ count: 1 })
|
||||
expect(await service.ensureCode('user')).toMatch(/^[23456789A-HJ-NP-Z]{6}$/)
|
||||
expect(prisma.user.updateMany).toHaveBeenCalledTimes(2)
|
||||
expect(prisma.user.updateMany.mock.calls[0][0].where).toEqual({ id: 'user', inviteCode: null })
|
||||
})
|
||||
it('reads the winning code when concurrent generation already filled it', async () => {
|
||||
const { prisma, service } = setup()
|
||||
prisma.user.findUniqueOrThrow.mockResolvedValueOnce({ inviteCode: null }).mockResolvedValueOnce({ inviteCode: 'ABC234' })
|
||||
prisma.user.updateMany.mockResolvedValue({ count: 0 })
|
||||
expect(await service.ensureCode('user')).toBe('ABC234')
|
||||
})
|
||||
it('rejects invalid, self and changed inviter codes', async () => {
|
||||
const { prisma, service } = setup()
|
||||
prisma.user.findUnique.mockResolvedValueOnce(null).mockResolvedValueOnce({ id: 'self' }).mockResolvedValueOnce({ id: 'friend' })
|
||||
await expect(service.confirmCode('self', 'ABC234')).rejects.toThrow('不存在')
|
||||
await expect(service.confirmCode('self', 'ABC234')).rejects.toThrow('自己')
|
||||
prisma.inviteReferral.upsert.mockResolvedValue({ inviterId: 'original' })
|
||||
await expect(service.confirmCode('self', 'ABC234')).rejects.toThrow('其他好友')
|
||||
})
|
||||
it('accepts repeat confirmation and recovers a concurrent binding to the same inviter', async () => {
|
||||
const { prisma, service } = setup()
|
||||
prisma.user.findUnique.mockResolvedValue({ id: 'friend' })
|
||||
prisma.user.findUniqueOrThrow.mockResolvedValue({ inviteCode: 'XYZ234' })
|
||||
prisma.inviteReferral.upsert.mockRejectedValueOnce({ code: 'P2002' }).mockResolvedValueOnce({ inviterId: 'friend' })
|
||||
prisma.inviteReferral.findUnique.mockResolvedValue({ inviterId: 'friend' })
|
||||
expect(await service.confirmCode('self', 'ABC234')).toEqual({ inviteCode: 'XYZ234', discountEligible: true })
|
||||
expect(await service.confirmCode('self', 'ABC234')).toEqual({ inviteCode: 'XYZ234', discountEligible: true })
|
||||
})
|
||||
|
||||
it.each(['TRIAL', null])('does not reward category %s', async (purchasedCategory) => {
|
||||
const { prisma, service } = setup()
|
||||
await service.rewardPaidOrder(prisma as unknown as Prisma.TransactionClient, { userId: 'user', inviteInviterId: 'friend', purchasedCategory }, new Date())
|
||||
expect(prisma.inviteReferral.updateMany).not.toHaveBeenCalled()
|
||||
await service.recordQualifiedTrialBooking('booking')
|
||||
expect(prisma.membership.create).not.toHaveBeenCalled()
|
||||
})
|
||||
it.each(['TIMES', 'DURATION'])('grants one usable class for %s even without an existing membership', async (purchasedCategory) => {
|
||||
const { prisma, service } = setup()
|
||||
prisma.inviteReferral.updateMany.mockResolvedValueOnce({ count: 1 }).mockResolvedValueOnce({ count: 0 })
|
||||
prisma.membership.create.mockResolvedValue({ id: 'reward' })
|
||||
const order = { userId: 'user', inviteInviterId: 'friend', purchasedCategory }
|
||||
const tx = prisma as unknown as Prisma.TransactionClient
|
||||
await service.rewardPaidOrder(tx, order, new Date())
|
||||
await service.rewardPaidOrder(tx, order, new Date())
|
||||
expect(prisma.membership.create).toHaveBeenCalledTimes(1)
|
||||
expect(prisma.membership.create).toHaveBeenCalledWith({ data: expect.objectContaining({ userId: 'friend', remainingTimes: 1, totalTimes: 1, status: 'ACTIVE' }) })
|
||||
expect(prisma.inviteRewardGrant.create).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -1,3 +1,3 @@
|
||||
export const INVITE_REWARD_REQUIRED_COUNT = 3
|
||||
export const INVITE_REWARD_REQUIRED_COUNT = 1
|
||||
export const INVITE_REWARD_TIMES = 1
|
||||
|
||||
|
||||
@@ -1,13 +1,30 @@
|
||||
import { Controller, Get, UseGuards } from '@nestjs/common'
|
||||
import { IsString, Matches } from 'class-validator'
|
||||
import { Body, Controller, Get, Post, UseGuards } from '@nestjs/common'
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator'
|
||||
import { InviteService } from './invite.service'
|
||||
|
||||
class ConfirmInviteDto {
|
||||
@IsString()
|
||||
@Matches(/^[23456789A-HJ-NP-Z]{6}$/i)
|
||||
code!: string
|
||||
}
|
||||
|
||||
@Controller('invite')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class InviteController {
|
||||
constructor(private readonly inviteService: InviteService) {}
|
||||
|
||||
@Get('code')
|
||||
getCode(@CurrentUser('sub') userId: string) {
|
||||
return this.inviteService.getCodeStatus(userId)
|
||||
}
|
||||
|
||||
@Post('confirm')
|
||||
confirm(@CurrentUser('sub') userId: string, @Body() body: ConfirmInviteDto) {
|
||||
return this.inviteService.confirmCode(userId, body.code)
|
||||
}
|
||||
|
||||
@Get('activity')
|
||||
getActivity(@CurrentUser('sub') userId: string) {
|
||||
return this.inviteService.getInviteActivitySummary(userId)
|
||||
|
||||
@@ -3,7 +3,8 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common'
|
||||
import type { InviteReferral, InviteRewardGrant, Membership } from '@prisma/client'
|
||||
import { randomInt } from 'crypto'
|
||||
import type { Prisma, InviteReferral, InviteRewardGrant } from '@prisma/client'
|
||||
import { InviteReferralStatus, MembershipStatus, OrderStatus } from '@mp-pilates/shared'
|
||||
import type { InviteActivitySummary } from '@mp-pilates/shared'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
@@ -70,50 +71,72 @@ export class InviteService {
|
||||
})
|
||||
}
|
||||
|
||||
async recordQualifiedTrialBooking(bookingId: string): Promise<void> {
|
||||
const booking = await this.prisma.booking.findUnique({
|
||||
where: { id: bookingId },
|
||||
include: {
|
||||
membership: { include: { cardType: true } },
|
||||
},
|
||||
})
|
||||
// Legacy booking callbacks must never qualify trial purchases.
|
||||
async recordQualifiedTrialBooking(_bookingId: string): Promise<void> {}
|
||||
|
||||
if (!booking || booking.status !== 'COMPLETED' || !this.isTrialCardType(booking.membership.cardType.type)) {
|
||||
return
|
||||
async ensureCode(userId: string): Promise<string> {
|
||||
for (let attempt = 0; attempt < 12; attempt++) {
|
||||
const user = await this.prisma.user.findUniqueOrThrow({ where: { id: userId } })
|
||||
if (user.inviteCode) return user.inviteCode
|
||||
const alphabet = '23456789ABCDEFGHJKLMNPQRSTUVWXYZ'
|
||||
const code = Array.from({ length: 6 }, () => alphabet[randomInt(alphabet.length)]).join('')
|
||||
try {
|
||||
const updated = await this.prisma.user.updateMany({
|
||||
where: { id: userId, inviteCode: null }, data: { inviteCode: code },
|
||||
})
|
||||
if (updated.count) return code
|
||||
} catch (error) {
|
||||
if ((error as { code?: string }).code !== 'P2002') throw error
|
||||
}
|
||||
}
|
||||
throw new BadRequestException('邀请码生成繁忙,请重试')
|
||||
}
|
||||
|
||||
const referral = await this.prisma.inviteReferral.findFirst({
|
||||
where: {
|
||||
inviteeId: booking.userId,
|
||||
status: {
|
||||
in: [InviteReferralStatus.REGISTERED, InviteReferralStatus.TRIAL_PURCHASED],
|
||||
},
|
||||
qualifiedBookingId: null,
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
async getCodeStatus(userId: string) {
|
||||
const inviteCode = await this.ensureCode(userId)
|
||||
const referral = await this.prisma.inviteReferral.findUnique({ where: { inviteeId: userId } })
|
||||
return { inviteCode, discountEligible: !!referral }
|
||||
}
|
||||
|
||||
if (!referral) {
|
||||
return
|
||||
async confirmCode(userId: string, code: string) {
|
||||
const inviter = await this.prisma.user.findUnique({ where: { inviteCode: code.toUpperCase() } })
|
||||
if (!inviter) throw new BadRequestException('邀请码不存在,请检查后重试')
|
||||
if (inviter.id === userId) throw new BadRequestException('不能使用自己的邀请码')
|
||||
let referral: InviteReferral | null
|
||||
try {
|
||||
referral = await this.prisma.inviteReferral.upsert({
|
||||
where: { inviteeId: userId }, update: {},
|
||||
create: { inviterId: inviter.id, inviteeId: userId },
|
||||
})
|
||||
} catch (error) {
|
||||
// MySQL upserts may race on the unique invitee key. Read the winner.
|
||||
if ((error as { code?: string }).code !== 'P2002') throw error
|
||||
referral = await this.prisma.inviteReferral.findUnique({ where: { inviteeId: userId } })
|
||||
if (!referral) throw error
|
||||
}
|
||||
if (referral.inviterId !== inviter.id) throw new BadRequestException('你已绑定其他好友的邀请码,已享有 95 折优惠')
|
||||
return this.getCodeStatus(userId)
|
||||
}
|
||||
|
||||
await this.prisma.inviteReferral.update({
|
||||
where: { id: referral.id },
|
||||
data: {
|
||||
status: InviteReferralStatus.QUALIFIED,
|
||||
qualifiedBookingId: booking.id,
|
||||
qualifiedAt: booking.completedAt ?? new Date(),
|
||||
},
|
||||
async rewardPaidOrder(tx: Prisma.TransactionClient, order: { userId: string; inviteInviterId: string | null; purchasedCategory: string | null }, now: Date) {
|
||||
if (!order.inviteInviterId || !order.purchasedCategory || order.purchasedCategory === 'TRIAL') return
|
||||
const claimed = await tx.inviteReferral.updateMany({
|
||||
where: { inviteeId: order.userId, inviterId: order.inviteInviterId, status: { not: 'QUALIFIED' } },
|
||||
data: { status: 'QUALIFIED', qualifiedAt: now },
|
||||
})
|
||||
|
||||
await this.grantRewardsIfEligible(referral.inviterId)
|
||||
if (!claimed.count) return
|
||||
const membership = await tx.membership.create({ data: {
|
||||
userId: order.inviteInviterId, cardTypeId: 'invite-reward-card',
|
||||
remainingTimes: 1, totalTimes: 1, startDate: now,
|
||||
expireDate: new Date(now.getTime() + 365 * 86400000), status: 'ACTIVE',
|
||||
} })
|
||||
await tx.inviteRewardGrant.create({ data: {
|
||||
inviterId: order.inviteInviterId, membershipId: membership.id,
|
||||
qualifiedReferralCount: 1, rewardTimes: 1,
|
||||
} })
|
||||
}
|
||||
|
||||
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: {
|
||||
@@ -132,7 +155,7 @@ export class InviteService {
|
||||
orderBy: { grantedAt: 'desc' },
|
||||
})
|
||||
|
||||
const canInvite = memberships.some((membership: Membership) => membership.status === MembershipStatus.ACTIVE)
|
||||
const canInvite = true
|
||||
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(
|
||||
@@ -144,7 +167,7 @@ export class InviteService {
|
||||
return {
|
||||
inviterId: userId,
|
||||
canInvite,
|
||||
sharePath: `/pages/profile/invite?inviterId=${userId}`,
|
||||
sharePath: `/pages/card/detail?showAll=1&inviteCode=${await this.ensureCode(userId)}`,
|
||||
rewardRuleInvitesRequired: INVITE_REWARD_REQUIRED_COUNT,
|
||||
rewardRuleTimes: INVITE_REWARD_TIMES,
|
||||
qualifiedInviteCount,
|
||||
@@ -196,58 +219,4 @@ export class InviteService {
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ const mockUser = {
|
||||
const mockInviteService = {
|
||||
validateInviterForTrialOrder: jest.fn(),
|
||||
recordTrialOrderPaid: jest.fn(),
|
||||
rewardPaidOrder: jest.fn(),
|
||||
}
|
||||
|
||||
const buildMockOrder = (overrides: Partial<Record<string, unknown>> = {}) => ({
|
||||
@@ -69,6 +70,7 @@ const mockPaymentParams = {
|
||||
|
||||
function buildPrismaMock() {
|
||||
return {
|
||||
inviteReferral: { findUnique: jest.fn().mockResolvedValue(null) },
|
||||
cardType: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
@@ -76,6 +78,7 @@ function buildPrismaMock() {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
order: {
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
create: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
update: jest.fn(),
|
||||
@@ -131,6 +134,16 @@ describe('PaymentService', () => {
|
||||
// ─── createOrder ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('createOrder', () => {
|
||||
it.each(['TRIAL', 'TIMES', 'DURATION'])('applies 95 percent server-side to %s and snapshots attribution', async (type) => {
|
||||
prisma.cardType.findUnique.mockResolvedValue({ ...mockCardType, type, price: new Decimal(999) })
|
||||
prisma.user.findUnique.mockResolvedValue(mockUser)
|
||||
prisma.inviteReferral.findUnique.mockResolvedValue({ inviterId: 'friend' })
|
||||
prisma.order.create.mockResolvedValue(buildMockOrder({ amount: new Decimal(949) }))
|
||||
await service.createOrder(mockUser.id, mockCardType.id)
|
||||
expect(prisma.order.create).toHaveBeenCalledWith({ data: expect.objectContaining({ amount: 949, inviteInviterId: 'friend', purchasedCategory: type }) })
|
||||
expect(wechat.createUnifiedOrder).toHaveBeenCalledWith(expect.objectContaining({ amount: 949 }))
|
||||
})
|
||||
|
||||
it('creates a PENDING order with correct amount and formatted orderNo', async () => {
|
||||
prisma.cardType.findUnique.mockResolvedValue(mockCardType)
|
||||
prisma.user.findUnique.mockResolvedValue(mockUser)
|
||||
@@ -146,7 +159,7 @@ describe('PaymentService', () => {
|
||||
data: expect.objectContaining({
|
||||
userId: mockUser.id,
|
||||
cardTypeId: mockCardType.id,
|
||||
amount: mockCardType.price,
|
||||
amount: Number(mockCardType.price),
|
||||
status: OrderStatus.PENDING,
|
||||
}),
|
||||
}),
|
||||
@@ -246,6 +259,18 @@ describe('PaymentService', () => {
|
||||
prisma.$transaction.mockImplementation(async (fn: (tx: typeof prisma) => Promise<unknown>) => fn(prisma))
|
||||
})
|
||||
|
||||
it('does not grant membership or referral rewards when another callback claimed the order', async () => {
|
||||
prisma.order.updateMany.mockResolvedValue({ count: 0 })
|
||||
await service.handleWxNotify(headers, successBody)
|
||||
expect(prisma.membership.create).not.toHaveBeenCalled()
|
||||
expect(mockInviteService.rewardPaidOrder).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('propagates reward issuance failure out of the payment transaction', async () => {
|
||||
mockInviteService.rewardPaidOrder.mockRejectedValueOnce(new Error('reward failed'))
|
||||
await expect(service.handleWxNotify(headers, successBody)).rejects.toThrow('reward failed')
|
||||
})
|
||||
|
||||
it('marks order as PAID and grants a new membership on valid callback', async () => {
|
||||
const result = await service.handleWxNotify(headers, successBody)
|
||||
|
||||
|
||||
@@ -55,6 +55,8 @@ export class PaymentService {
|
||||
await this.inviteService.validateInviterForTrialOrder(userId, inviterId)
|
||||
}
|
||||
|
||||
const referral = await this.prisma.inviteReferral.findUnique({ where: { inviteeId: userId } })
|
||||
const amount = referral ? Math.round(Number(cardType.price) * 95 / 100) : Number(cardType.price)
|
||||
const orderNo = `${Date.now()}${Math.random().toString(36).substring(2, 8)}`
|
||||
|
||||
const order = await this.prisma.order.create({
|
||||
@@ -62,14 +64,16 @@ export class PaymentService {
|
||||
userId,
|
||||
cardTypeId,
|
||||
orderNo,
|
||||
amount: cardType.price,
|
||||
amount,
|
||||
inviteInviterId: referral?.inviterId,
|
||||
purchasedCategory: cardType.type,
|
||||
status: OrderStatus.PENDING,
|
||||
},
|
||||
})
|
||||
|
||||
const paymentParams = await this.wechatPayService.createUnifiedOrder({
|
||||
orderNo,
|
||||
amount: Number(cardType.price),
|
||||
amount,
|
||||
openid: user.openid,
|
||||
description: cardType.name,
|
||||
})
|
||||
@@ -122,6 +126,11 @@ export class PaymentService {
|
||||
const now = new Date()
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const claimed = await tx.order.updateMany({
|
||||
where: { id: existingOrder.id, status: OrderStatus.PENDING },
|
||||
data: { status: OrderStatus.PAID },
|
||||
})
|
||||
if (!claimed.count) return
|
||||
const membership = await this.membershipService.grantPurchasedCard(
|
||||
tx,
|
||||
existingOrder.userId,
|
||||
@@ -138,6 +147,7 @@ export class PaymentService {
|
||||
membershipId: membership.id,
|
||||
},
|
||||
})
|
||||
await this.inviteService.rewardPaidOrder(tx, existingOrder, now)
|
||||
})
|
||||
|
||||
await this.inviteService.recordTrialOrderPaid(existingOrder.id)
|
||||
|
||||
Reference in New Issue
Block a user