feat: 支持同卡种续卡叠加并修复过期登录态

支付成功后将次数或有效期叠加到已有会员卡,首页与详情页引导续卡;接口 401 时同步清掉本地登录态。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
richarjiang
2026-09-07 14:04:11 +08:00
parent 801e2f47da
commit 88cd8419c8
22 changed files with 1370 additions and 123 deletions

View File

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

View File

@@ -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()', () => {

View File

@@ -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[]> {

View File

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

View File

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

View File

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

View File

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