feat: 支持同卡种续卡叠加并修复过期登录态
支付成功后将次数或有效期叠加到已有会员卡,首页与详情页引导续卡;接口 401 时同步清掉本地登录态。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `memberships` ADD COLUMN `total_times` INTEGER NULL;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE `orders` ADD COLUMN `membership_id` VARCHAR(191) NULL;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX `memberships_user_id_card_type_id_idx` ON `memberships`(`user_id`, `card_type_id`);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX `orders_membership_id_idx` ON `orders`(`membership_id`);
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `orders` ADD CONSTRAINT `orders_membership_id_fkey` FOREIGN KEY (`membership_id`) REFERENCES `memberships`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- Backfill purchased-times snapshot for existing times/trial cards
|
||||
UPDATE `memberships` `m`
|
||||
INNER JOIN `card_types` `ct` ON `ct`.`id` = `m`.`card_type_id`
|
||||
SET `m`.`total_times` = `ct`.`total_times`
|
||||
WHERE `ct`.`total_times` IS NOT NULL AND `m`.`total_times` IS NULL;
|
||||
@@ -147,6 +147,7 @@ model Membership {
|
||||
userId String @map("user_id")
|
||||
cardTypeId String @map("card_type_id")
|
||||
remainingTimes Int? @map("remaining_times")
|
||||
totalTimes Int? @map("total_times")
|
||||
startDate DateTime @map("start_date")
|
||||
expireDate DateTime @map("expire_date")
|
||||
status MembershipStatus @default(ACTIVE)
|
||||
@@ -156,9 +157,11 @@ model Membership {
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
cardType CardType @relation(fields: [cardTypeId], references: [id])
|
||||
bookings Booking[]
|
||||
orders Order[]
|
||||
inviteRewardGrants InviteRewardGrant[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([userId, cardTypeId])
|
||||
@@index([status])
|
||||
@@map("memberships")
|
||||
}
|
||||
@@ -245,6 +248,7 @@ model Order {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
cardTypeId String @map("card_type_id")
|
||||
membershipId String? @map("membership_id")
|
||||
orderNo String @unique @map("order_no")
|
||||
amount Decimal @db.Decimal(10, 0)
|
||||
status OrderStatus @default(PENDING)
|
||||
@@ -256,11 +260,13 @@ model Order {
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
cardType CardType @relation(fields: [cardTypeId], references: [id])
|
||||
membership Membership? @relation(fields: [membershipId], references: [id])
|
||||
flashSaleOrder FlashSaleOrder?
|
||||
inviteReferrals InviteReferral[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
@@index([membershipId])
|
||||
@@map("orders")
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
import {
|
||||
CardTypeCategory,
|
||||
MembershipStatus,
|
||||
RENEWAL_DAYS_THRESHOLD,
|
||||
RENEWAL_TIMES_THRESHOLD,
|
||||
computeMembershipGrant,
|
||||
getMembershipRenewalHint,
|
||||
pickRenewalTarget,
|
||||
} from '@mp-pilates/shared'
|
||||
import type { RenewalHintMembership } from '@mp-pilates/shared'
|
||||
|
||||
const now = new Date('2026-06-01T00:00:00Z')
|
||||
|
||||
const timesCard = {
|
||||
type: CardTypeCategory.TIMES,
|
||||
totalTimes: 10,
|
||||
durationDays: 90,
|
||||
}
|
||||
|
||||
const durationCard = {
|
||||
type: CardTypeCategory.DURATION,
|
||||
totalTimes: null,
|
||||
durationDays: 30,
|
||||
}
|
||||
|
||||
const trialCard = {
|
||||
type: CardTypeCategory.TRIAL,
|
||||
totalTimes: 1,
|
||||
durationDays: 7,
|
||||
}
|
||||
|
||||
function membership(overrides: Partial<RenewalHintMembership> & { cardTypeId: string }): RenewalHintMembership {
|
||||
return {
|
||||
remainingTimes: null,
|
||||
expireDate: '2026-12-01T00:00:00.000Z',
|
||||
status: MembershipStatus.ACTIVE,
|
||||
cardType: { type: CardTypeCategory.TIMES },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('computeMembershipGrant', () => {
|
||||
it('creates a fresh grant when there is no existing membership', () => {
|
||||
const result = computeMembershipGrant({ existing: null, cardType: timesCard, now })
|
||||
|
||||
expect(result.isRenewal).toBe(false)
|
||||
expect(result.remainingTimes).toBe(10)
|
||||
expect(result.totalTimes).toBe(10)
|
||||
expect(result.expireDate.getTime()).toBe(now.getTime() + 90 * 86_400_000)
|
||||
})
|
||||
|
||||
it('always treats TRIAL as a new grant even if an old trial exists', () => {
|
||||
const result = computeMembershipGrant({
|
||||
existing: {
|
||||
remainingTimes: 0,
|
||||
totalTimes: 1,
|
||||
expireDate: '2026-08-01T00:00:00.000Z',
|
||||
status: MembershipStatus.ACTIVE,
|
||||
},
|
||||
cardType: trialCard,
|
||||
now,
|
||||
})
|
||||
|
||||
expect(result.isRenewal).toBe(false)
|
||||
expect(result.remainingTimes).toBe(1)
|
||||
expect(result.totalTimes).toBe(1)
|
||||
})
|
||||
|
||||
it('stacks times and extends expireDate for an active TIMES card', () => {
|
||||
const expireDate = new Date('2026-09-01T00:00:00Z')
|
||||
const result = computeMembershipGrant({
|
||||
existing: {
|
||||
remainingTimes: 3,
|
||||
totalTimes: 10,
|
||||
expireDate,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
},
|
||||
cardType: timesCard,
|
||||
now,
|
||||
})
|
||||
|
||||
expect(result.isRenewal).toBe(true)
|
||||
expect(result.remainingTimes).toBe(13)
|
||||
expect(result.totalTimes).toBe(20)
|
||||
expect(result.expireDate.getTime()).toBe(expireDate.getTime() + 90 * 86_400_000)
|
||||
})
|
||||
|
||||
it('does not inherit leftover times when the TIMES card is expired', () => {
|
||||
const result = computeMembershipGrant({
|
||||
existing: {
|
||||
remainingTimes: 4,
|
||||
totalTimes: 10,
|
||||
expireDate: '2026-01-01T00:00:00.000Z',
|
||||
status: MembershipStatus.EXPIRED,
|
||||
},
|
||||
cardType: timesCard,
|
||||
now,
|
||||
})
|
||||
|
||||
expect(result.isRenewal).toBe(true)
|
||||
expect(result.remainingTimes).toBe(10)
|
||||
expect(result.totalTimes).toBe(10)
|
||||
expect(result.expireDate.getTime()).toBe(now.getTime() + 90 * 86_400_000)
|
||||
})
|
||||
|
||||
it('extends a still-active DURATION card from its current expireDate', () => {
|
||||
const expireDate = new Date('2026-07-01T00:00:00Z')
|
||||
const result = computeMembershipGrant({
|
||||
existing: {
|
||||
remainingTimes: null,
|
||||
totalTimes: null,
|
||||
expireDate,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
},
|
||||
cardType: durationCard,
|
||||
now,
|
||||
})
|
||||
|
||||
expect(result.isRenewal).toBe(true)
|
||||
expect(result.remainingTimes).toBeNull()
|
||||
expect(result.expireDate.getTime()).toBe(expireDate.getTime() + 30 * 86_400_000)
|
||||
})
|
||||
|
||||
it('starts an expired DURATION card from now', () => {
|
||||
const result = computeMembershipGrant({
|
||||
existing: {
|
||||
remainingTimes: null,
|
||||
totalTimes: null,
|
||||
expireDate: '2026-01-01T00:00:00.000Z',
|
||||
status: MembershipStatus.EXPIRED,
|
||||
},
|
||||
cardType: durationCard,
|
||||
now,
|
||||
})
|
||||
|
||||
expect(result.isRenewal).toBe(true)
|
||||
expect(result.expireDate.getTime()).toBe(now.getTime() + 30 * 86_400_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMembershipRenewalHint', () => {
|
||||
it('prefers a TIMES card running low over a DURATION card near expiry', () => {
|
||||
const hint = getMembershipRenewalHint(
|
||||
[
|
||||
membership({
|
||||
cardTypeId: 'duration-1',
|
||||
remainingTimes: null,
|
||||
expireDate: new Date(now.getTime() + 3 * 86_400_000).toISOString(),
|
||||
cardType: { type: CardTypeCategory.DURATION },
|
||||
}),
|
||||
membership({
|
||||
cardTypeId: 'times-1',
|
||||
remainingTimes: RENEWAL_TIMES_THRESHOLD,
|
||||
expireDate: new Date(now.getTime() + 60 * 86_400_000).toISOString(),
|
||||
cardType: { type: CardTypeCategory.TIMES },
|
||||
}),
|
||||
],
|
||||
now,
|
||||
)
|
||||
|
||||
expect(hint).toMatchObject({
|
||||
kind: 'times_low',
|
||||
cardTypeId: 'times-1',
|
||||
remainingTimes: RENEWAL_TIMES_THRESHOLD,
|
||||
})
|
||||
})
|
||||
|
||||
it('returns days_low when a DURATION card is within the threshold', () => {
|
||||
const hint = getMembershipRenewalHint(
|
||||
[
|
||||
membership({
|
||||
cardTypeId: 'duration-1',
|
||||
remainingTimes: null,
|
||||
expireDate: new Date(now.getTime() + RENEWAL_DAYS_THRESHOLD * 86_400_000).toISOString(),
|
||||
cardType: { type: CardTypeCategory.DURATION },
|
||||
}),
|
||||
],
|
||||
now,
|
||||
)
|
||||
|
||||
expect(hint).toMatchObject({
|
||||
kind: 'days_low',
|
||||
cardTypeId: 'duration-1',
|
||||
daysLeft: RENEWAL_DAYS_THRESHOLD,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not recommend renewing a TRIAL card when times run low', () => {
|
||||
const hint = getMembershipRenewalHint(
|
||||
[
|
||||
membership({
|
||||
cardTypeId: 'trial-1',
|
||||
remainingTimes: 1,
|
||||
expireDate: new Date(now.getTime() + 5 * 86_400_000).toISOString(),
|
||||
cardType: { type: CardTypeCategory.TRIAL },
|
||||
}),
|
||||
],
|
||||
now,
|
||||
)
|
||||
|
||||
expect(hint).toMatchObject({
|
||||
kind: 'trial_low',
|
||||
cardTypeId: null,
|
||||
remainingTimes: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('recommends the most recently expired non-trial card', () => {
|
||||
const hint = getMembershipRenewalHint(
|
||||
[
|
||||
membership({
|
||||
cardTypeId: 'times-old',
|
||||
remainingTimes: 0,
|
||||
expireDate: '2026-01-01T00:00:00.000Z',
|
||||
status: MembershipStatus.EXPIRED,
|
||||
cardType: { type: CardTypeCategory.TIMES },
|
||||
}),
|
||||
membership({
|
||||
cardTypeId: 'duration-latest',
|
||||
remainingTimes: null,
|
||||
expireDate: '2026-05-01T00:00:00.000Z',
|
||||
status: MembershipStatus.EXPIRED,
|
||||
cardType: { type: CardTypeCategory.DURATION },
|
||||
}),
|
||||
membership({
|
||||
cardTypeId: 'trial-1',
|
||||
remainingTimes: 0,
|
||||
expireDate: '2026-05-15T00:00:00.000Z',
|
||||
status: MembershipStatus.USED_UP,
|
||||
cardType: { type: CardTypeCategory.TRIAL },
|
||||
}),
|
||||
],
|
||||
now,
|
||||
)
|
||||
|
||||
expect(hint).toMatchObject({
|
||||
kind: 'expired',
|
||||
cardTypeId: 'duration-latest',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null when active cards are healthy', () => {
|
||||
const hint = getMembershipRenewalHint(
|
||||
[
|
||||
membership({
|
||||
cardTypeId: 'times-1',
|
||||
remainingTimes: 8,
|
||||
expireDate: new Date(now.getTime() + 60 * 86_400_000).toISOString(),
|
||||
}),
|
||||
],
|
||||
now,
|
||||
)
|
||||
|
||||
expect(hint).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('pickRenewalTarget', () => {
|
||||
it('picks the latest expiring membership of the same card type', () => {
|
||||
const target = pickRenewalTarget(
|
||||
[
|
||||
membership({
|
||||
cardTypeId: 'times-1',
|
||||
expireDate: '2026-01-01T00:00:00.000Z',
|
||||
}),
|
||||
membership({
|
||||
cardTypeId: 'times-1',
|
||||
expireDate: '2026-08-01T00:00:00.000Z',
|
||||
}),
|
||||
membership({
|
||||
cardTypeId: 'duration-1',
|
||||
expireDate: '2026-12-01T00:00:00.000Z',
|
||||
cardType: { type: CardTypeCategory.DURATION },
|
||||
}),
|
||||
],
|
||||
'times-1',
|
||||
)
|
||||
|
||||
expect(target?.expireDate).toBe('2026-08-01T00:00:00.000Z')
|
||||
})
|
||||
|
||||
it('does not pick a TRIAL membership as a renewal target', () => {
|
||||
const target = pickRenewalTarget(
|
||||
[
|
||||
membership({
|
||||
cardTypeId: 'trial-1',
|
||||
cardType: { type: CardTypeCategory.TRIAL },
|
||||
}),
|
||||
],
|
||||
'trial-1',
|
||||
)
|
||||
|
||||
expect(target).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,7 @@ const mockTimesCardType = {
|
||||
price: 150000,
|
||||
originalPrice: null,
|
||||
description: null,
|
||||
coverUrl: null,
|
||||
isActive: true,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
@@ -31,6 +32,7 @@ const mockDurationCardType = {
|
||||
price: 80000,
|
||||
originalPrice: null,
|
||||
description: null,
|
||||
coverUrl: null,
|
||||
isActive: true,
|
||||
sortOrder: 1,
|
||||
createdAt: new Date('2024-01-01T00:00:00Z'),
|
||||
@@ -48,6 +50,7 @@ const mockActiveMembership = {
|
||||
userId: 'user-001',
|
||||
cardTypeId: mockTimesCardType.id,
|
||||
remainingTimes: 5,
|
||||
totalTimes: 10,
|
||||
startDate: new Date('2024-01-01T00:00:00Z'),
|
||||
expireDate: new Date('2099-12-31T00:00:00Z'),
|
||||
status: MembershipStatus.ACTIVE,
|
||||
@@ -61,6 +64,7 @@ const mockDurationMembership = {
|
||||
id: 'mem-duration-001',
|
||||
cardTypeId: mockDurationCardType.id,
|
||||
remainingTimes: null,
|
||||
totalTimes: null,
|
||||
cardType: mockDurationCardType,
|
||||
}
|
||||
|
||||
@@ -77,6 +81,7 @@ const mockPrismaService = {
|
||||
findMany: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
}
|
||||
@@ -300,6 +305,160 @@ describe('MembershipService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// ─── grantPurchasedCard ────────────────────────────────────────────────
|
||||
|
||||
describe('grantPurchasedCard()', () => {
|
||||
const now = new Date('2026-06-01T00:00:00Z')
|
||||
const tx = mockPrismaService as unknown as Parameters<MembershipService['grantPurchasedCard']>[0]
|
||||
|
||||
it('creates a new membership when the user has no card of that type', async () => {
|
||||
mockPrismaService.membership.findFirst.mockResolvedValue(null)
|
||||
const created = { ...mockActiveMembership, remainingTimes: 10, totalTimes: 10 }
|
||||
mockPrismaService.membership.create.mockResolvedValue(created)
|
||||
|
||||
const result = await service.grantPurchasedCard(tx, 'user-001', mockTimesCardType, now)
|
||||
|
||||
expect(mockPrismaService.membership.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
userId: 'user-001',
|
||||
cardTypeId: mockTimesCardType.id,
|
||||
remainingTimes: 10,
|
||||
totalTimes: 10,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(mockPrismaService.membership.update).not.toHaveBeenCalled()
|
||||
expect(result.remainingTimes).toBe(10)
|
||||
})
|
||||
|
||||
it('stacks remaining times and extends expireDate for an active TIMES card', async () => {
|
||||
const existing = {
|
||||
...mockActiveMembership,
|
||||
remainingTimes: 3,
|
||||
totalTimes: 10,
|
||||
expireDate: new Date('2026-09-01T00:00:00Z'),
|
||||
}
|
||||
mockPrismaService.membership.findFirst.mockResolvedValue(existing)
|
||||
mockPrismaService.membership.update.mockResolvedValue({
|
||||
...existing,
|
||||
remainingTimes: 13,
|
||||
totalTimes: 20,
|
||||
})
|
||||
|
||||
await service.grantPurchasedCard(tx, 'user-001', mockTimesCardType, now)
|
||||
|
||||
expect(mockPrismaService.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: existing.id },
|
||||
data: expect.objectContaining({
|
||||
remainingTimes: 13,
|
||||
totalTimes: 20,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const updateData = mockPrismaService.membership.update.mock.calls[0][0].data as {
|
||||
expireDate: Date
|
||||
}
|
||||
expect(updateData.expireDate.getTime()).toBe(
|
||||
existing.expireDate.getTime() + mockTimesCardType.durationDays * 86_400_000,
|
||||
)
|
||||
expect(mockPrismaService.membership.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('extends a DURATION card from the current expireDate when still active', async () => {
|
||||
const existing = {
|
||||
...mockDurationMembership,
|
||||
expireDate: new Date('2026-07-01T00:00:00Z'),
|
||||
}
|
||||
mockPrismaService.membership.findFirst.mockResolvedValue(existing)
|
||||
mockPrismaService.membership.update.mockResolvedValue(existing)
|
||||
|
||||
await service.grantPurchasedCard(tx, 'user-001', mockDurationCardType, now)
|
||||
|
||||
expect(mockPrismaService.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
remainingTimes: null,
|
||||
totalTimes: null,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const updateData = mockPrismaService.membership.update.mock.calls[0][0].data as {
|
||||
expireDate: Date
|
||||
}
|
||||
expect(updateData.expireDate.getTime()).toBe(
|
||||
existing.expireDate.getTime() + mockDurationCardType.durationDays * 86_400_000,
|
||||
)
|
||||
})
|
||||
|
||||
it('does not carry leftover times when renewing an expired TIMES card', async () => {
|
||||
const existing = {
|
||||
...mockActiveMembership,
|
||||
remainingTimes: 4,
|
||||
totalTimes: 10,
|
||||
status: MembershipStatus.EXPIRED,
|
||||
expireDate: new Date('2026-01-01T00:00:00Z'),
|
||||
}
|
||||
mockPrismaService.membership.findFirst.mockResolvedValue(existing)
|
||||
mockPrismaService.membership.update.mockResolvedValue({
|
||||
...existing,
|
||||
remainingTimes: 10,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
})
|
||||
|
||||
await service.grantPurchasedCard(tx, 'user-001', mockTimesCardType, now)
|
||||
|
||||
expect(mockPrismaService.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
remainingTimes: 10,
|
||||
totalTimes: 10,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
const updateData = mockPrismaService.membership.update.mock.calls[0][0].data as {
|
||||
expireDate: Date
|
||||
}
|
||||
expect(updateData.expireDate.getTime()).toBe(
|
||||
now.getTime() + mockTimesCardType.durationDays * 86_400_000,
|
||||
)
|
||||
})
|
||||
|
||||
it('always creates a new membership for TRIAL cards', async () => {
|
||||
const trialCardType = {
|
||||
...mockTimesCardType,
|
||||
id: 'ct-trial-001',
|
||||
type: CardTypeCategory.TRIAL,
|
||||
totalTimes: 1,
|
||||
durationDays: 7,
|
||||
}
|
||||
mockPrismaService.membership.create.mockResolvedValue({
|
||||
...mockActiveMembership,
|
||||
cardTypeId: trialCardType.id,
|
||||
remainingTimes: 1,
|
||||
totalTimes: 1,
|
||||
})
|
||||
|
||||
await service.grantPurchasedCard(tx, 'user-001', trialCardType, now)
|
||||
|
||||
expect(mockPrismaService.membership.findFirst).not.toHaveBeenCalled()
|
||||
expect(mockPrismaService.membership.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
cardTypeId: trialCardType.id,
|
||||
remainingTimes: 1,
|
||||
totalTimes: 1,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── createCardType ────────────────────────────────────────────────────
|
||||
|
||||
describe('createCardType()', () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'
|
||||
import { CardType, Membership } from '@prisma/client'
|
||||
import { CardTypeCategory, MembershipStatus } from '@mp-pilates/shared'
|
||||
import { CardType, Membership, Prisma } from '@prisma/client'
|
||||
import { CardTypeCategory, MembershipStatus, computeMembershipGrant } from '@mp-pilates/shared'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
import { CreateCardTypeDto } from './dto/create-card-type.dto'
|
||||
import { UpdateCardTypeDto } from './dto/update-card-type.dto'
|
||||
@@ -119,6 +119,55 @@ export class MembershipService {
|
||||
return { ...updated, cardType: { ...updated.cardType } }
|
||||
}
|
||||
|
||||
async grantPurchasedCard(
|
||||
tx: Prisma.TransactionClient,
|
||||
userId: string,
|
||||
cardType: Pick<CardType, 'id' | 'type' | 'totalTimes' | 'durationDays'>,
|
||||
now = new Date(),
|
||||
): Promise<Membership> {
|
||||
const existing =
|
||||
cardType.type === CardTypeCategory.TRIAL
|
||||
? null
|
||||
: await tx.membership.findFirst({
|
||||
where: { userId, cardTypeId: cardType.id },
|
||||
orderBy: { expireDate: 'desc' },
|
||||
})
|
||||
|
||||
const grant = computeMembershipGrant({
|
||||
existing,
|
||||
cardType: {
|
||||
type: cardType.type,
|
||||
totalTimes: cardType.totalTimes,
|
||||
durationDays: cardType.durationDays,
|
||||
},
|
||||
now,
|
||||
})
|
||||
|
||||
if (!grant.isRenewal || !existing) {
|
||||
return tx.membership.create({
|
||||
data: {
|
||||
userId,
|
||||
cardTypeId: cardType.id,
|
||||
startDate: now,
|
||||
expireDate: grant.expireDate,
|
||||
remainingTimes: grant.remainingTimes,
|
||||
totalTimes: grant.totalTimes,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return tx.membership.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
remainingTimes: grant.remainingTimes,
|
||||
totalTimes: grant.totalTimes,
|
||||
expireDate: grant.expireDate,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Admin ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async getAllCardTypes(): Promise<CardType[]> {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { PaymentService } from '../payment.service'
|
||||
import { WechatPayService } from '../wechat-pay.service'
|
||||
import { PrismaService } from '../../prisma/prisma.service'
|
||||
import { InviteService } from '../../invite/invite.service'
|
||||
import { MembershipService } from '../../membership/membership.service'
|
||||
|
||||
// ─── Fixtures ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -19,6 +20,7 @@ const mockCardType = {
|
||||
type: 'TIMES',
|
||||
originalPrice: null,
|
||||
description: null,
|
||||
coverUrl: null,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
@@ -82,6 +84,11 @@ function buildPrismaMock() {
|
||||
},
|
||||
membership: {
|
||||
create: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
flashSaleOrder: {
|
||||
updateMany: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn(),
|
||||
}
|
||||
@@ -109,6 +116,7 @@ describe('PaymentService', () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
PaymentService,
|
||||
MembershipService,
|
||||
{ provide: PrismaService, useValue: prisma },
|
||||
{ provide: WechatPayService, useValue: wechat },
|
||||
{ provide: InviteService, useValue: mockInviteService },
|
||||
@@ -226,31 +234,33 @@ describe('PaymentService', () => {
|
||||
})
|
||||
prisma.order.findUnique.mockResolvedValue(pendingOrder)
|
||||
prisma.cardType.findUnique.mockResolvedValue(mockCardType)
|
||||
prisma.$transaction.mockResolvedValue([])
|
||||
prisma.membership.findFirst.mockResolvedValue(null)
|
||||
prisma.membership.create.mockResolvedValue({
|
||||
id: 'mem-new-1',
|
||||
userId: pendingOrder.userId,
|
||||
cardTypeId: pendingOrder.cardTypeId,
|
||||
remainingTimes: mockCardType.totalTimes,
|
||||
totalTimes: mockCardType.totalTimes,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
})
|
||||
prisma.$transaction.mockImplementation(async (fn: (tx: typeof prisma) => Promise<unknown>) => fn(prisma))
|
||||
})
|
||||
|
||||
it('marks order as PAID and creates membership on valid callback', async () => {
|
||||
it('marks order as PAID and grants a new membership on valid callback', async () => {
|
||||
const result = await service.handleWxNotify(headers, successBody)
|
||||
|
||||
// $transaction called once with an array of two operations
|
||||
expect(prisma.$transaction).toHaveBeenCalledTimes(1)
|
||||
const [transactionOps] = prisma.$transaction.mock.calls[0] as [unknown[]]
|
||||
expect(transactionOps).toHaveLength(2)
|
||||
|
||||
// order.update was called with PAID status and transaction id
|
||||
expect(prisma.order.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
status: OrderStatus.PAID,
|
||||
wxTransactionId: successBody.transaction_id,
|
||||
membershipId: 'mem-new-1',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
// membership.create was called
|
||||
expect(prisma.membership.create).toHaveBeenCalledTimes(1)
|
||||
expect(mockInviteService.recordTrialOrderPaid).toHaveBeenCalledWith(pendingOrder.id)
|
||||
|
||||
expect(result).toContain('SUCCESS')
|
||||
})
|
||||
|
||||
@@ -275,7 +285,7 @@ describe('PaymentService', () => {
|
||||
|
||||
const expectedExpireMs =
|
||||
membershipData.startDate.getTime() + mockCardType.durationDays * 86_400_000
|
||||
expect(membershipData.expireDate.getTime()).toBeCloseTo(expectedExpireMs, -2) // within 100ms
|
||||
expect(membershipData.expireDate.getTime()).toBeCloseTo(expectedExpireMs, -2)
|
||||
expect(membershipData.startDate.getTime()).toBeGreaterThanOrEqual(beforeCall)
|
||||
})
|
||||
|
||||
@@ -285,14 +295,15 @@ describe('PaymentService', () => {
|
||||
expect(prisma.membership.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
remainingTimes: mockCardType.totalTimes, // 10
|
||||
remainingTimes: mockCardType.totalTimes,
|
||||
totalTimes: mockCardType.totalTimes,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('creates membership with null remainingTimes for duration-based cardType', async () => {
|
||||
const durationCardType = { ...mockCardType, totalTimes: null }
|
||||
const durationCardType = { ...mockCardType, totalTimes: null, type: 'DURATION' }
|
||||
prisma.cardType.findUnique.mockResolvedValue(durationCardType)
|
||||
|
||||
await service.handleWxNotify(headers, successBody)
|
||||
@@ -301,6 +312,46 @@ describe('PaymentService', () => {
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
remainingTimes: null,
|
||||
totalTimes: null,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('renews an existing same-type membership instead of creating another', async () => {
|
||||
const existingMembership = {
|
||||
id: 'mem-existing-1',
|
||||
userId: pendingOrder.userId,
|
||||
cardTypeId: pendingOrder.cardTypeId,
|
||||
remainingTimes: 2,
|
||||
totalTimes: 10,
|
||||
expireDate: new Date('2099-01-01T00:00:00Z'),
|
||||
status: MembershipStatus.ACTIVE,
|
||||
}
|
||||
prisma.membership.findFirst.mockResolvedValue(existingMembership)
|
||||
prisma.membership.update.mockResolvedValue({
|
||||
...existingMembership,
|
||||
remainingTimes: 12,
|
||||
totalTimes: 20,
|
||||
})
|
||||
|
||||
await service.handleWxNotify(headers, successBody)
|
||||
|
||||
expect(prisma.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'mem-existing-1' },
|
||||
data: expect.objectContaining({
|
||||
remainingTimes: 12,
|
||||
totalTimes: 20,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(prisma.membership.create).not.toHaveBeenCalled()
|
||||
expect(prisma.order.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
membershipId: 'mem-existing-1',
|
||||
}),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -4,9 +4,10 @@ import { PaymentService } from './payment.service'
|
||||
import { PaymentController } from './payment.controller'
|
||||
import { WechatPayService } from './wechat-pay.service'
|
||||
import { InviteModule } from '../invite/invite.module'
|
||||
import { MembershipModule } from '../membership/membership.module'
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, InviteModule],
|
||||
imports: [PrismaModule, InviteModule, MembershipModule],
|
||||
controllers: [PaymentController],
|
||||
providers: [PaymentService, WechatPayService],
|
||||
exports: [PaymentService, WechatPayService],
|
||||
|
||||
@@ -5,10 +5,11 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common'
|
||||
import { CardType, Order } from '@prisma/client'
|
||||
import { MembershipStatus, OrderStatus, FlashSaleOrderStatus } from '@mp-pilates/shared'
|
||||
import { 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'
|
||||
import { MembershipService } from '../membership/membership.service'
|
||||
|
||||
export interface CreateOrderResult {
|
||||
order: Order
|
||||
@@ -30,6 +31,7 @@ export class PaymentService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly wechatPayService: WechatPayService,
|
||||
private readonly inviteService: InviteService,
|
||||
private readonly membershipService: MembershipService,
|
||||
) {}
|
||||
|
||||
// ─── User: create order ────────────────────────────────────────────────────
|
||||
@@ -118,32 +120,29 @@ export class PaymentService {
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const expireDate = new Date(now.getTime() + cardType.durationDays * 86_400_000)
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.order.update({
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
const membership = await this.membershipService.grantPurchasedCard(
|
||||
tx,
|
||||
existingOrder.userId,
|
||||
cardType,
|
||||
now,
|
||||
)
|
||||
|
||||
await tx.order.update({
|
||||
where: { id: existingOrder.id },
|
||||
data: {
|
||||
status: OrderStatus.PAID,
|
||||
wxTransactionId: notification.wxTransactionId,
|
||||
paidAt: now,
|
||||
membershipId: membership.id,
|
||||
},
|
||||
}),
|
||||
this.prisma.membership.create({
|
||||
data: {
|
||||
userId: existingOrder.userId,
|
||||
cardTypeId: existingOrder.cardTypeId,
|
||||
startDate: now,
|
||||
expireDate,
|
||||
remainingTimes: cardType.totalTimes ?? null,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
},
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
await this.inviteService.recordTrialOrderPaid(existingOrder.id)
|
||||
|
||||
this.logger.log(`Order PAID and Membership created: orderNo=${notification.orderNo}`)
|
||||
this.logger.log(`Order PAID and membership granted: orderNo=${notification.orderNo}`)
|
||||
|
||||
// ── Flash sale order: mark as PAID ──
|
||||
if (existingOrder.flashSaleId) {
|
||||
|
||||
@@ -440,7 +440,7 @@ export class UserService {
|
||||
}
|
||||
|
||||
return this.prisma.membership.create({
|
||||
data: { userId, ...data },
|
||||
data: { userId, totalTimes: dto.remainingTimes ?? null, ...data },
|
||||
include: { cardType: true },
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user