fix: 修复月卡无法编辑次数的问题
This commit is contained in:
@@ -36,7 +36,7 @@
|
||||
<!-- Membership card selection -->
|
||||
<view class="card-section">
|
||||
<view class="section-label-row">
|
||||
<text class="section-label">选择扣课会员卡</text>
|
||||
<text class="section-label">选择会员卡</text>
|
||||
</view>
|
||||
|
||||
<!-- Single membership -->
|
||||
@@ -96,7 +96,9 @@
|
||||
<!-- Deduction tip -->
|
||||
<view class="deduction-tip" v-if="selectedMembership">
|
||||
<text class="deduction-text">
|
||||
确认后将从「{{ selectedMembership.cardType.name }}」扣除 1 次课时
|
||||
{{ selectedMembership.remainingTimes === null
|
||||
? `「${selectedMembership.cardType.name}」有效期内不限次`
|
||||
: `确认后将从「${selectedMembership.cardType.name}」扣除 1 次课时` }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
|
||||
@@ -57,7 +57,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import { CardTypeCategory } from '@mp-pilates/shared'
|
||||
import { getErrorMessage } from '../utils/auth'
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -105,7 +104,7 @@ const activeMembershipLabel = computed(() => {
|
||||
if (!active.length) return ''
|
||||
const m = active[0]
|
||||
const cardName = m.cardType.name
|
||||
if (m.cardType.type === CardTypeCategory.TIMES && m.remainingTimes !== null) {
|
||||
if (m.remainingTimes !== null) {
|
||||
return `${cardName} · 剩余 ${m.remainingTimes} 次`
|
||||
}
|
||||
const expire = new Date(m.expireDate)
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { UserProfileResponse, UserStatsResponse, MembershipWithCardType } from '@mp-pilates/shared'
|
||||
import { CardTypeCategory, MembershipStatus } from '@mp-pilates/shared'
|
||||
import { MembershipStatus } from '@mp-pilates/shared'
|
||||
|
||||
const props = defineProps<{
|
||||
loggedIn: boolean
|
||||
@@ -133,10 +133,10 @@ function toSafeCount(value: number | null | undefined): number {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
}
|
||||
|
||||
// Sum remaining sessions from all active time-based memberships
|
||||
// Sum remaining sessions from all active count-limited memberships.
|
||||
const remainingSessions = computed(() =>
|
||||
activeMemberships.value
|
||||
.filter((m) => m.cardType.type === CardTypeCategory.TIMES)
|
||||
.filter((m) => m.remainingTimes !== null)
|
||||
.reduce((sum, m) => sum + toSafeCount(m.remainingTimes), 0),
|
||||
)
|
||||
|
||||
|
||||
@@ -150,7 +150,7 @@
|
||||
class="modal-input"
|
||||
type="number"
|
||||
v-model="form.totalTimesStr"
|
||||
placeholder="次卡必填,月卡留空"
|
||||
placeholder="次卡必填;月卡可填写次数"
|
||||
placeholder-style="color:#bbb"
|
||||
/>
|
||||
</view>
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
>
|
||||
<text class="card-pill-name">{{ card.cardType.name }}</text>
|
||||
<text class="card-pill-meta">
|
||||
{{ card.remainingTimes == null ? '月卡不扣次' : `剩 ${card.remainingTimes} 次` }}
|
||||
{{ card.remainingTimes === null ? '有效期内不限次' : `剩 ${card.remainingTimes} 次` }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -146,11 +146,11 @@ const selectedMembership = computed(
|
||||
|
||||
const canArrange = computed(() => Boolean(selectedMembership.value))
|
||||
|
||||
const isDurationCard = computed(() => selectedMembership.value?.remainingTimes == null)
|
||||
const isCountLimited = computed(() => selectedMembership.value?.remainingTimes !== null)
|
||||
|
||||
const deductHint = computed(() => {
|
||||
if (isDurationCard.value) {
|
||||
return '将立即确认该课,月卡不扣次,会员无需再确认。'
|
||||
if (!isCountLimited.value) {
|
||||
return '将立即确认该课,会员卡不限次,会员无需再确认。'
|
||||
}
|
||||
return '将立即确认该课并扣除 1 次,会员无需再确认。'
|
||||
})
|
||||
|
||||
@@ -55,9 +55,16 @@
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view v-if="isTimeBasedCard" class="field">
|
||||
<text class="field-label">剩余次数</text>
|
||||
<input class="field-input" type="number" v-model="editForm.remainingTimes" placeholder="请输入剩余次数" />
|
||||
<view v-if="selectedCardType" class="field">
|
||||
<text class="field-label">
|
||||
{{ canLeaveTimesEmpty ? '剩余次数(留空不限次)' : '剩余次数' }}
|
||||
</text>
|
||||
<input
|
||||
class="field-input"
|
||||
type="number"
|
||||
v-model="editForm.remainingTimes"
|
||||
:placeholder="canLeaveTimesEmpty ? '留空表示不限次' : '请输入剩余次数'"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="field">
|
||||
@@ -124,7 +131,7 @@ const editForm = ref({
|
||||
membershipId: '' as string | '',
|
||||
cardTypeIndex: 0,
|
||||
cardTypeId: '',
|
||||
remainingTimes: null as number | null,
|
||||
remainingTimes: null as number | string | null,
|
||||
startDate: '',
|
||||
expireDate: '',
|
||||
manuallyEditedExpire: false,
|
||||
@@ -137,9 +144,15 @@ const membershipPickerLabels = computed(() =>
|
||||
existingMemberships.value.map((item) => `${item.cardType.name} · ${item.status}`),
|
||||
)
|
||||
|
||||
const isTimeBasedCard = computed(() => {
|
||||
const card = cardTypes.value[editForm.value.cardTypeIndex]
|
||||
return card && (card.type === 'TIMES' || card.type === 'TRIAL')
|
||||
const selectedCardType = computed(() => cardTypes.value[editForm.value.cardTypeIndex] ?? null)
|
||||
const canLeaveTimesEmpty = computed(() => {
|
||||
const cardType = selectedCardType.value
|
||||
const membership = editingMembership.value
|
||||
if (!cardType || cardType.type !== 'DURATION') return false
|
||||
|
||||
return cardType.totalTimes === null || (
|
||||
membership?.cardTypeId === cardType.id && membership.remainingTimes === null
|
||||
)
|
||||
})
|
||||
|
||||
function calculateExpireDate(startDate: string, durationDays: number): string {
|
||||
@@ -183,11 +196,11 @@ function onMembershipPick(e: { detail: { value: number } }) {
|
||||
function onCardTypeChange(e: { detail: { value: number } }) {
|
||||
const idx = Number(e.detail.value)
|
||||
const cardType = cardTypes.value[idx]
|
||||
if (!cardType) return
|
||||
|
||||
editForm.value.cardTypeIndex = idx
|
||||
editForm.value.cardTypeId = cardType.id
|
||||
if (cardType.totalTimes != null) {
|
||||
editForm.value.remainingTimes = cardType.totalTimes
|
||||
}
|
||||
editForm.value.remainingTimes = cardType.totalTimes
|
||||
if (!editForm.value.manuallyEditedExpire) {
|
||||
editForm.value.startDate = formatDateLocal(new Date())
|
||||
editForm.value.expireDate = calculateExpireDate(formatDateLocal(new Date()), cardType.durationDays)
|
||||
@@ -209,6 +222,17 @@ function onExpireDateChange(e: { detail: { value: string } }) {
|
||||
editForm.value.manuallyEditedExpire = true
|
||||
}
|
||||
|
||||
function parseRemainingTimes(): number | null | undefined {
|
||||
const rawValue = editForm.value.remainingTimes
|
||||
if (rawValue === null || (typeof rawValue === 'string' && rawValue.trim() === '')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const remainingTimes = Number(rawValue)
|
||||
if (!Number.isInteger(remainingTimes) || remainingTimes < 0) return undefined
|
||||
return remainingTimes
|
||||
}
|
||||
|
||||
async function loadPage() {
|
||||
pageLoading.value = true
|
||||
try {
|
||||
@@ -237,6 +261,17 @@ async function onSave() {
|
||||
uni.showToast({ title: '请选择卡类型', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const remainingTimes = parseRemainingTimes()
|
||||
if (remainingTimes === undefined) {
|
||||
uni.showToast({ title: '剩余次数需为非负整数', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (remainingTimes === null && !canLeaveTimesEmpty.value) {
|
||||
uni.showToast({ title: '请输入剩余次数', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
await adminStore.updateMemberProfile(userId.value, {
|
||||
@@ -246,7 +281,7 @@ async function onSave() {
|
||||
await adminStore.updateUserMembership(userId.value, {
|
||||
...(editForm.value.membershipId ? { membershipId: editForm.value.membershipId } : {}),
|
||||
cardTypeId: editForm.value.cardTypeId,
|
||||
remainingTimes: isTimeBasedCard.value ? Number(editForm.value.remainingTimes) || 0 : null,
|
||||
remainingTimes,
|
||||
startDate: editForm.value.startDate,
|
||||
expireDate: editForm.value.expireDate,
|
||||
})
|
||||
|
||||
@@ -207,13 +207,13 @@
|
||||
</view>
|
||||
<view v-if="cardData.totalTimes" class="feature-item">
|
||||
<text class="feature-dot">•</text>
|
||||
<text class="feature-text">每次预约扣除 1 次课时(次卡)</text>
|
||||
<text class="feature-text">每次预约扣除 1 次课时</text>
|
||||
</view>
|
||||
<view v-if="cardData.type === CardTypeCategory.DURATION" class="feature-item">
|
||||
<text class="feature-dot">•</text>
|
||||
<text class="feature-text">如需退卡,已上课时按次卡单价计费扣除,不以月卡优惠价结算</text>
|
||||
</view>
|
||||
<view v-if="cardData.type === CardTypeCategory.DURATION" class="feature-item">
|
||||
<view v-if="cardData.type === CardTypeCategory.DURATION && !cardData.totalTimes" class="feature-item">
|
||||
<text class="feature-dot">•</text>
|
||||
<text class="feature-text">到期后自动失效</text>
|
||||
</view>
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `bookings` ADD COLUMN `membership_times_deducted` BOOLEAN NULL;
|
||||
@@ -205,17 +205,18 @@ model TimeSlot {
|
||||
}
|
||||
|
||||
model Booking {
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
timeSlotId String @map("time_slot_id")
|
||||
membershipId String @map("membership_id")
|
||||
status BookingStatus @default(PENDING_CONFIRMATION)
|
||||
cancelledAt DateTime? @map("cancelled_at")
|
||||
confirmedAt DateTime? @map("confirmed_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
operatorId String? @map("operator_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
id String @id @default(uuid())
|
||||
userId String @map("user_id")
|
||||
timeSlotId String @map("time_slot_id")
|
||||
membershipId String @map("membership_id")
|
||||
membershipTimesDeducted Boolean? @map("membership_times_deducted")
|
||||
status BookingStatus @default(PENDING_CONFIRMATION)
|
||||
cancelledAt DateTime? @map("cancelled_at")
|
||||
confirmedAt DateTime? @map("confirmed_at")
|
||||
completedAt DateTime? @map("completed_at")
|
||||
operatorId String? @map("operator_id")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
timeSlot TimeSlot @relation(fields: [timeSlotId], references: [id])
|
||||
|
||||
@@ -85,6 +85,13 @@ const mockDurationMembership = {
|
||||
cardType: mockDurationCardType,
|
||||
}
|
||||
|
||||
const mockLimitedDurationMembership = {
|
||||
...mockDurationMembership,
|
||||
id: 'mem-duration-limited-001',
|
||||
remainingTimes: 5,
|
||||
totalTimes: 10,
|
||||
}
|
||||
|
||||
const mockExpiredMembership = {
|
||||
...mockActiveMembership,
|
||||
id: 'mem-expired-001',
|
||||
@@ -102,6 +109,7 @@ const mockConfirmedBooking = {
|
||||
userId: MOCK_USER_ID,
|
||||
timeSlotId: MOCK_SLOT_ID,
|
||||
membershipId: MOCK_MEMBERSHIP_ID,
|
||||
membershipTimesDeducted: true,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
cancelledAt: null,
|
||||
createdAt: new Date(),
|
||||
@@ -274,6 +282,95 @@ describe('BookingService', () => {
|
||||
courseName: 'FocusCore Pilates',
|
||||
bookingEndTime: '2099-12-31 10:00',
|
||||
})
|
||||
expect(tx.booking.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ membershipTimesDeducted: true }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('deducts a count-limited DURATION membership on confirmation', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.booking.findUnique.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockLimitedDurationMembership.id,
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
timeSlot: mockOpenSlot,
|
||||
membership: mockLimitedDurationMembership,
|
||||
})
|
||||
tx.booking.update.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockLimitedDurationMembership.id,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
})
|
||||
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
|
||||
tx.membership.update.mockResolvedValue({
|
||||
...mockLimitedDurationMembership,
|
||||
remainingTimes: 4,
|
||||
})
|
||||
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockLimitedDurationMembership.id,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
timeSlot: mockOpenSlot,
|
||||
membership: mockLimitedDurationMembership,
|
||||
})
|
||||
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ openid: 'openid-001' })
|
||||
studioService.getInfo.mockResolvedValue(mockStudioConfig)
|
||||
subscriptionMessageService.sendBookingConfirmedMessage.mockResolvedValue(true)
|
||||
|
||||
await service.confirmBooking(MOCK_BOOKING_ID, 'admin-001')
|
||||
|
||||
expect(tx.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: mockLimitedDurationMembership.id },
|
||||
data: { remainingTimes: 4, status: MembershipStatus.ACTIVE },
|
||||
}),
|
||||
)
|
||||
expect(tx.booking.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ membershipTimesDeducted: true }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('records no deduction for an unlimited DURATION membership on confirmation', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.booking.findUnique.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockDurationMembership.id,
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
timeSlot: mockOpenSlot,
|
||||
membership: mockDurationMembership,
|
||||
})
|
||||
tx.booking.update.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockDurationMembership.id,
|
||||
membershipTimesDeducted: false,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
})
|
||||
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
|
||||
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockDurationMembership.id,
|
||||
membershipTimesDeducted: false,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
timeSlot: mockOpenSlot,
|
||||
membership: mockDurationMembership,
|
||||
})
|
||||
|
||||
await service.confirmBooking(MOCK_BOOKING_ID, 'admin-001')
|
||||
|
||||
expect(tx.membership.update).not.toHaveBeenCalled()
|
||||
expect(tx.booking.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ membershipTimesDeducted: false }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -349,6 +446,7 @@ describe('BookingService', () => {
|
||||
userId: MOCK_USER_ID,
|
||||
timeSlotId: MOCK_SLOT_ID,
|
||||
membershipId: MOCK_MEMBERSHIP_ID,
|
||||
membershipTimesDeducted: false,
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
}),
|
||||
}),
|
||||
@@ -596,6 +694,7 @@ describe('BookingService', () => {
|
||||
where: { id: MOCK_BOOKING_ID },
|
||||
data: {
|
||||
membershipId: MOCK_MEMBERSHIP_ID,
|
||||
membershipTimesDeducted: false,
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
cancelledAt: null,
|
||||
confirmedAt: null,
|
||||
@@ -682,6 +781,100 @@ describe('BookingService', () => {
|
||||
expect(result.refunded).toBe(true)
|
||||
})
|
||||
|
||||
it('restores a count-limited DURATION membership when cancelled within the limit', async () => {
|
||||
const limitedDurationMembership = {
|
||||
...mockLimitedDurationMembership,
|
||||
remainingTimes: 4,
|
||||
}
|
||||
const bookingWithRelations = {
|
||||
...mockConfirmedBooking,
|
||||
membershipId: limitedDurationMembership.id,
|
||||
timeSlot: { ...futureSlot, bookedCount: 1 },
|
||||
membership: limitedDurationMembership,
|
||||
}
|
||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(bookingWithRelations)
|
||||
|
||||
const tx = buildTxMock()
|
||||
tx.booking.update.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
membershipId: limitedDurationMembership.id,
|
||||
status: BookingStatus.CANCELLED,
|
||||
})
|
||||
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
|
||||
tx.membership.update.mockResolvedValue({
|
||||
...limitedDurationMembership,
|
||||
remainingTimes: 5,
|
||||
})
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)
|
||||
|
||||
expect(tx.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: limitedDurationMembership.id },
|
||||
data: expect.objectContaining({
|
||||
remainingTimes: 5,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
expect(result.refunded).toBe(true)
|
||||
})
|
||||
|
||||
it('does not restore an unlimited DURATION membership when cancelled within the limit', async () => {
|
||||
const bookingWithRelations = {
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockDurationMembership.id,
|
||||
membershipTimesDeducted: false,
|
||||
timeSlot: { ...futureSlot, bookedCount: 1 },
|
||||
membership: mockDurationMembership,
|
||||
}
|
||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(bookingWithRelations)
|
||||
|
||||
const tx = buildTxMock()
|
||||
tx.booking.update.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockDurationMembership.id,
|
||||
status: BookingStatus.CANCELLED,
|
||||
})
|
||||
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)
|
||||
|
||||
expect(tx.membership.update).not.toHaveBeenCalled()
|
||||
expect(result.refunded).toBe(false)
|
||||
})
|
||||
|
||||
it('does not refund an originally unlimited membership after it is changed to counted', async () => {
|
||||
const bookingWithRelations = {
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockLimitedDurationMembership.id,
|
||||
membershipTimesDeducted: false,
|
||||
timeSlot: { ...futureSlot, bookedCount: 1 },
|
||||
membership: {
|
||||
...mockLimitedDurationMembership,
|
||||
remainingTimes: 10,
|
||||
},
|
||||
}
|
||||
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(bookingWithRelations)
|
||||
|
||||
const tx = buildTxMock()
|
||||
tx.booking.update.mockResolvedValue({
|
||||
...mockConfirmedBooking,
|
||||
membershipId: mockLimitedDurationMembership.id,
|
||||
membershipTimesDeducted: false,
|
||||
status: BookingStatus.CANCELLED,
|
||||
})
|
||||
tx.timeSlot.update.mockResolvedValue({ ...futureSlot, bookedCount: 0 })
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
const result = await service.cancelBooking(MOCK_USER_ID, MOCK_BOOKING_ID)
|
||||
|
||||
expect(tx.membership.update).not.toHaveBeenCalled()
|
||||
expect(result.refunded).toBe(false)
|
||||
})
|
||||
|
||||
it('cancels booking past limit: does NOT refund membership', async () => {
|
||||
const bookingWithImminent = {
|
||||
...mockConfirmedBooking,
|
||||
@@ -1034,7 +1227,10 @@ describe('BookingService', () => {
|
||||
function stubArrangeSuccess(
|
||||
tx: ReturnType<typeof buildTxMock>,
|
||||
options?: {
|
||||
membership?: typeof mockActiveMembership | typeof mockDurationMembership | typeof mockTrialMembership
|
||||
membership?: typeof mockActiveMembership
|
||||
| typeof mockDurationMembership
|
||||
| typeof mockLimitedDurationMembership
|
||||
| typeof mockTrialMembership
|
||||
slot?: typeof mockOpenSlot
|
||||
existing?: typeof mockConfirmedBooking | null
|
||||
},
|
||||
@@ -1093,6 +1289,7 @@ describe('BookingService', () => {
|
||||
userId: MOCK_USER_ID,
|
||||
timeSlotId: MOCK_SLOT_ID,
|
||||
membershipId: MOCK_MEMBERSHIP_ID,
|
||||
membershipTimesDeducted: true,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
operatorId: MOCK_ADMIN_ID,
|
||||
}),
|
||||
@@ -1127,7 +1324,7 @@ describe('BookingService', () => {
|
||||
expect(result.status).toBe(BookingStatus.CONFIRMED)
|
||||
})
|
||||
|
||||
it('does not deduct remaining times for duration cards', async () => {
|
||||
it('does not deduct remaining times for unlimited duration cards', async () => {
|
||||
const tx = buildTxMock()
|
||||
stubArrangeSuccess(tx, { membership: mockDurationMembership })
|
||||
|
||||
@@ -1137,7 +1334,33 @@ describe('BookingService', () => {
|
||||
})
|
||||
|
||||
expect(tx.membership.update).not.toHaveBeenCalled()
|
||||
expect(tx.booking.create).toHaveBeenCalled()
|
||||
expect(tx.booking.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ membershipTimesDeducted: false }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('deducts remaining times for count-limited duration cards', async () => {
|
||||
const tx = buildTxMock()
|
||||
stubArrangeSuccess(tx, { membership: mockLimitedDurationMembership })
|
||||
|
||||
await service.adminArrangeBooking(MOCK_ADMIN_ID, {
|
||||
...dto,
|
||||
membershipId: mockLimitedDurationMembership.id,
|
||||
})
|
||||
|
||||
expect(tx.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: mockLimitedDurationMembership.id },
|
||||
data: { remainingTimes: 4, status: MembershipStatus.ACTIVE },
|
||||
}),
|
||||
)
|
||||
expect(tx.booking.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({ membershipTimesDeducted: true }),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('deducts a trial card session', async () => {
|
||||
|
||||
@@ -115,18 +115,9 @@ export class BookingService {
|
||||
)
|
||||
}
|
||||
|
||||
const cardType = membership.cardType
|
||||
const isTimeBased =
|
||||
cardType.type === CardTypeCategory.TIMES ||
|
||||
cardType.type === CardTypeCategory.TRIAL
|
||||
|
||||
if (isTimeBased) {
|
||||
// 4a. TIMES / TRIAL: must have remaining times (check at confirm time, not booking time)
|
||||
} else {
|
||||
// 4b. DURATION: must not be expired
|
||||
if (membership.expireDate <= new Date()) {
|
||||
throw new BadRequestException('Membership has expired')
|
||||
}
|
||||
// A card cannot be used after its validity period ends.
|
||||
if (membership.expireDate <= new Date()) {
|
||||
throw new BadRequestException('Membership has expired')
|
||||
}
|
||||
|
||||
// 5. Create booking or revive a previously cancelled booking.
|
||||
@@ -135,6 +126,7 @@ export class BookingService {
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
membershipId: dto.membershipId,
|
||||
membershipTimesDeducted: false,
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
cancelledAt: null,
|
||||
confirmedAt: null,
|
||||
@@ -147,6 +139,7 @@ export class BookingService {
|
||||
userId,
|
||||
timeSlotId: dto.timeSlotId,
|
||||
membershipId: dto.membershipId,
|
||||
membershipTimesDeducted: false,
|
||||
status: BookingStatus.PENDING_CONFIRMATION,
|
||||
},
|
||||
})
|
||||
@@ -202,19 +195,16 @@ export class BookingService {
|
||||
}
|
||||
|
||||
// 2. Validate membership still has available times
|
||||
const cardType = existing.membership.cardType
|
||||
const isTimeBased =
|
||||
cardType.type === CardTypeCategory.TIMES ||
|
||||
cardType.type === CardTypeCategory.TRIAL
|
||||
if (existing.membership.expireDate <= new Date()) {
|
||||
throw new BadRequestException('Membership has expired')
|
||||
}
|
||||
|
||||
if (isTimeBased) {
|
||||
const isCountLimited = existing.membership.remainingTimes !== null
|
||||
|
||||
if (isCountLimited) {
|
||||
if ((existing.membership.remainingTimes ?? 0) <= 0) {
|
||||
throw new BadRequestException('No remaining times on this membership')
|
||||
}
|
||||
} else {
|
||||
if (existing.membership.expireDate <= new Date()) {
|
||||
throw new BadRequestException('Membership has expired')
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Update booking status to CONFIRMED
|
||||
@@ -222,6 +212,7 @@ export class BookingService {
|
||||
where: { id: bookingId },
|
||||
data: {
|
||||
status: BookingStatus.CONFIRMED,
|
||||
membershipTimesDeducted: isCountLimited,
|
||||
confirmedAt: new Date(),
|
||||
operatorId,
|
||||
},
|
||||
@@ -241,7 +232,7 @@ export class BookingService {
|
||||
})
|
||||
|
||||
// 5. Deduct membership times
|
||||
if (isTimeBased) {
|
||||
if (isCountLimited) {
|
||||
const newRemainingTimes = (existing.membership.remainingTimes ?? 0) - 1
|
||||
const newMembershipStatus =
|
||||
newRemainingTimes <= 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE
|
||||
@@ -333,12 +324,9 @@ export class BookingService {
|
||||
throw new BadRequestException('Membership has expired')
|
||||
}
|
||||
|
||||
const cardType = membership.cardType
|
||||
const isTimeBased =
|
||||
cardType.type === CardTypeCategory.TIMES ||
|
||||
cardType.type === CardTypeCategory.TRIAL
|
||||
const isCountLimited = membership.remainingTimes !== null
|
||||
|
||||
if (isTimeBased && (membership.remainingTimes ?? 0) <= 0) {
|
||||
if (isCountLimited && (membership.remainingTimes ?? 0) <= 0) {
|
||||
throw new BadRequestException('No remaining times on this membership')
|
||||
}
|
||||
|
||||
@@ -370,6 +358,7 @@ export class BookingService {
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
membershipId: dto.membershipId,
|
||||
membershipTimesDeducted: isCountLimited,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
cancelledAt: null,
|
||||
confirmedAt: now,
|
||||
@@ -382,13 +371,14 @@ export class BookingService {
|
||||
userId: dto.userId,
|
||||
timeSlotId: timeSlot.id,
|
||||
membershipId: dto.membershipId,
|
||||
membershipTimesDeducted: isCountLimited,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
confirmedAt: now,
|
||||
operatorId,
|
||||
},
|
||||
})
|
||||
|
||||
if (isTimeBased) {
|
||||
if (isCountLimited) {
|
||||
const newRemainingTimes = (membership.remainingTimes ?? 0) - 1
|
||||
await tx.membership.update({
|
||||
where: { id: membership.id },
|
||||
@@ -574,13 +564,18 @@ export class BookingService {
|
||||
})
|
||||
|
||||
// Conditionally restore membership
|
||||
if (withinLimit) {
|
||||
const cardType = booking.membership.cardType
|
||||
const isTimeBased =
|
||||
cardType.type === CardTypeCategory.TIMES ||
|
||||
cardType.type === CardTypeCategory.TRIAL
|
||||
// Legacy bookings predate the snapshot. Keep their former category-based
|
||||
// refund behavior instead of inferring from a count that may be edited.
|
||||
const membershipTimesDeducted = booking.membershipTimesDeducted ?? (
|
||||
booking.membership.cardType.type === CardTypeCategory.TIMES ||
|
||||
booking.membership.cardType.type === CardTypeCategory.TRIAL
|
||||
)
|
||||
|
||||
if (isTimeBased) {
|
||||
if (withinLimit && membershipTimesDeducted) {
|
||||
// The snapshot reflects confirmation-time behavior. The card may have
|
||||
// been edited after confirmation, so its current count must not decide
|
||||
// whether this booking earns a refund.
|
||||
if (booking.membership.remainingTimes !== null) {
|
||||
const newRemainingTimes = (booking.membership.remainingTimes ?? 0) + 1
|
||||
const newStatus =
|
||||
booking.membership.status === MembershipStatus.USED_UP
|
||||
|
||||
@@ -23,6 +23,12 @@ const durationCard = {
|
||||
durationDays: 30,
|
||||
}
|
||||
|
||||
const limitedDurationCard = {
|
||||
type: CardTypeCategory.DURATION,
|
||||
totalTimes: 10,
|
||||
durationDays: 30,
|
||||
}
|
||||
|
||||
const trialCard = {
|
||||
type: CardTypeCategory.TRIAL,
|
||||
totalTimes: 1,
|
||||
@@ -136,6 +142,27 @@ describe('computeMembershipGrant', () => {
|
||||
expect(result.isRenewal).toBe(true)
|
||||
expect(result.expireDate.getTime()).toBe(now.getTime() + 30 * 86_400_000)
|
||||
})
|
||||
|
||||
it('stacks sessions for an active count-limited DURATION card', () => {
|
||||
const expireDate = new Date('2026-07-01T00:00:00Z')
|
||||
const result = computeMembershipGrant({
|
||||
existing: {
|
||||
remainingTimes: 3,
|
||||
totalTimes: 10,
|
||||
expireDate,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
},
|
||||
cardType: limitedDurationCard,
|
||||
now,
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
isRenewal: true,
|
||||
remainingTimes: 13,
|
||||
totalTimes: 20,
|
||||
})
|
||||
expect(result.expireDate.getTime()).toBe(expireDate.getTime() + 30 * 86_400_000)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getMembershipRenewalHint', () => {
|
||||
@@ -185,6 +212,26 @@ describe('getMembershipRenewalHint', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('returns times_low for a count-limited DURATION card', () => {
|
||||
const hint = getMembershipRenewalHint(
|
||||
[
|
||||
membership({
|
||||
cardTypeId: 'limited-duration-1',
|
||||
remainingTimes: RENEWAL_TIMES_THRESHOLD,
|
||||
expireDate: new Date(now.getTime() + 20 * 86_400_000).toISOString(),
|
||||
cardType: { type: CardTypeCategory.DURATION },
|
||||
}),
|
||||
],
|
||||
now,
|
||||
)
|
||||
|
||||
expect(hint).toMatchObject({
|
||||
kind: 'times_low',
|
||||
cardTypeId: 'limited-duration-1',
|
||||
remainingTimes: RENEWAL_TIMES_THRESHOLD,
|
||||
})
|
||||
})
|
||||
|
||||
it('does not recommend renewing a TRIAL card when times run low', () => {
|
||||
const hint = getMembershipRenewalHint(
|
||||
[
|
||||
|
||||
@@ -224,7 +224,7 @@ describe('MembershipService', () => {
|
||||
expect(result.remainingTimes).toBe(0)
|
||||
})
|
||||
|
||||
it('should not change times for a DURATION card', async () => {
|
||||
it('should leave an unlimited DURATION card unchanged', async () => {
|
||||
mockPrismaService.membership.findUnique.mockResolvedValue(mockDurationMembership)
|
||||
|
||||
const result = await service.deductMembership('mem-duration-001')
|
||||
@@ -234,6 +234,27 @@ describe('MembershipService', () => {
|
||||
expect(result.status).toBe(MembershipStatus.ACTIVE)
|
||||
})
|
||||
|
||||
it('should decrement remainingTimes for a count-limited DURATION card', async () => {
|
||||
const membership = {
|
||||
...mockDurationMembership,
|
||||
remainingTimes: 5,
|
||||
totalTimes: 10,
|
||||
}
|
||||
mockPrismaService.membership.findUnique.mockResolvedValue(membership)
|
||||
mockPrismaService.membership.update.mockResolvedValue({
|
||||
...membership,
|
||||
remainingTimes: 4,
|
||||
})
|
||||
|
||||
await service.deductMembership('mem-duration-001')
|
||||
|
||||
expect(mockPrismaService.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: { remainingTimes: 4, status: MembershipStatus.ACTIVE },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw NotFoundException when membership does not exist', async () => {
|
||||
mockPrismaService.membership.findUnique.mockResolvedValue(null)
|
||||
|
||||
@@ -298,6 +319,39 @@ describe('MembershipService', () => {
|
||||
expect(result.remainingTimes).toBe(4)
|
||||
})
|
||||
|
||||
it('should leave an unlimited DURATION card unchanged', async () => {
|
||||
mockPrismaService.membership.findUnique.mockResolvedValue(mockDurationMembership)
|
||||
|
||||
const result = await service.restoreMembership('mem-duration-001')
|
||||
|
||||
expect(mockPrismaService.membership.update).not.toHaveBeenCalled()
|
||||
expect(result.remainingTimes).toBeNull()
|
||||
expect(result.status).toBe(MembershipStatus.ACTIVE)
|
||||
})
|
||||
|
||||
it('should restore a count-limited DURATION card from USED_UP', async () => {
|
||||
const membership = {
|
||||
...mockDurationMembership,
|
||||
remainingTimes: 0,
|
||||
totalTimes: 10,
|
||||
status: MembershipStatus.USED_UP,
|
||||
}
|
||||
mockPrismaService.membership.findUnique.mockResolvedValue(membership)
|
||||
mockPrismaService.membership.update.mockResolvedValue({
|
||||
...membership,
|
||||
remainingTimes: 1,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
})
|
||||
|
||||
await service.restoreMembership('mem-duration-001')
|
||||
|
||||
expect(mockPrismaService.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: { remainingTimes: 1, status: MembershipStatus.ACTIVE },
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('should throw NotFoundException when membership does not exist', async () => {
|
||||
mockPrismaService.membership.findUnique.mockResolvedValue(null)
|
||||
|
||||
|
||||
@@ -59,12 +59,8 @@ export class MembershipService {
|
||||
throw new BadRequestException(`Membership ${membershipId} is not active`)
|
||||
}
|
||||
|
||||
const isTimeBased =
|
||||
membership.cardType.type === CardTypeCategory.TIMES ||
|
||||
membership.cardType.type === CardTypeCategory.TRIAL
|
||||
|
||||
if (!isTimeBased) {
|
||||
// DURATION card: validate expiry only, no times to deduct
|
||||
if (membership.remainingTimes === null) {
|
||||
// Unlimited memberships do not consume a session.
|
||||
return { ...membership, cardType: { ...membership.cardType } }
|
||||
}
|
||||
|
||||
@@ -93,11 +89,7 @@ export class MembershipService {
|
||||
throw new NotFoundException(`Membership ${membershipId} not found`)
|
||||
}
|
||||
|
||||
const isTimeBased =
|
||||
membership.cardType.type === CardTypeCategory.TIMES ||
|
||||
membership.cardType.type === CardTypeCategory.TRIAL
|
||||
|
||||
if (!isTimeBased) {
|
||||
if (membership.remainingTimes === null) {
|
||||
return { ...membership, cardType: { ...membership.cardType } }
|
||||
}
|
||||
|
||||
|
||||
@@ -544,6 +544,123 @@ describe('UserService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('updateUserMembership', () => {
|
||||
const startDate = '2026-09-01'
|
||||
const expireDate = '2099-10-01'
|
||||
|
||||
it('snapshots a submitted count when creating a DURATION membership', async () => {
|
||||
mockPrisma.membership.findFirst.mockResolvedValue(null)
|
||||
mockPrisma.membership.create.mockResolvedValue({ id: 'mem-duration-001' })
|
||||
|
||||
await service.updateUserMembership('user-1', {
|
||||
cardTypeId: 'card-duration-001',
|
||||
remainingTimes: 10,
|
||||
startDate,
|
||||
expireDate,
|
||||
})
|
||||
|
||||
expect(mockPrisma.membership.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
userId: 'user-1',
|
||||
cardTypeId: 'card-duration-001',
|
||||
remainingTimes: 10,
|
||||
totalTimes: 10,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves the existing count snapshot when the submitted remaining count does not exceed it', async () => {
|
||||
mockPrisma.membership.findFirst.mockResolvedValue({
|
||||
id: 'mem-duration-001',
|
||||
userId: 'user-1',
|
||||
cardTypeId: 'card-duration-001',
|
||||
remainingTimes: 6,
|
||||
totalTimes: 10,
|
||||
})
|
||||
mockPrisma.membership.update.mockResolvedValue({ id: 'mem-duration-001' })
|
||||
|
||||
await service.updateUserMembership('user-1', {
|
||||
membershipId: 'mem-duration-001',
|
||||
cardTypeId: 'card-duration-001',
|
||||
remainingTimes: 8,
|
||||
startDate,
|
||||
expireDate,
|
||||
})
|
||||
|
||||
expect(mockPrisma.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 'mem-duration-001' },
|
||||
data: expect.objectContaining({
|
||||
cardTypeId: 'card-duration-001',
|
||||
remainingTimes: 8,
|
||||
totalTimes: 10,
|
||||
status: MembershipStatus.ACTIVE,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('raises the count snapshot when the submitted remaining count exceeds it', async () => {
|
||||
mockPrisma.membership.findFirst.mockResolvedValue({
|
||||
id: 'mem-duration-001',
|
||||
userId: 'user-1',
|
||||
cardTypeId: 'card-duration-001',
|
||||
remainingTimes: 6,
|
||||
totalTimes: 10,
|
||||
})
|
||||
mockPrisma.membership.update.mockResolvedValue({ id: 'mem-duration-001' })
|
||||
|
||||
await service.updateUserMembership('user-1', {
|
||||
membershipId: 'mem-duration-001',
|
||||
cardTypeId: 'card-duration-001',
|
||||
remainingTimes: 12,
|
||||
startDate,
|
||||
expireDate,
|
||||
})
|
||||
|
||||
expect(mockPrisma.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
remainingTimes: 12,
|
||||
totalTimes: 12,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('resets the count snapshot when changing to a different card type', async () => {
|
||||
mockPrisma.membership.findFirst.mockResolvedValue({
|
||||
id: 'mem-duration-001',
|
||||
userId: 'user-1',
|
||||
cardTypeId: 'card-duration-001',
|
||||
remainingTimes: 6,
|
||||
totalTimes: 10,
|
||||
})
|
||||
mockPrisma.membership.update.mockResolvedValue({ id: 'mem-duration-001' })
|
||||
|
||||
await service.updateUserMembership('user-1', {
|
||||
membershipId: 'mem-duration-001',
|
||||
cardTypeId: 'card-duration-002',
|
||||
remainingTimes: 8,
|
||||
startDate,
|
||||
expireDate,
|
||||
})
|
||||
|
||||
expect(mockPrisma.membership.update).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
cardTypeId: 'card-duration-002',
|
||||
remainingTimes: 8,
|
||||
totalTimes: 8,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteUserMembership', () => {
|
||||
it('expires only the selected membership', async () => {
|
||||
mockPrisma.membership.findFirst.mockResolvedValue({
|
||||
|
||||
@@ -581,18 +581,18 @@ export class UserService {
|
||||
const now = new Date()
|
||||
const expireDate = new Date(dto.expireDate)
|
||||
const remainingTimes = dto.remainingTimes ?? null
|
||||
const isTimeBased = remainingTimes !== null
|
||||
const isCountLimited = remainingTimes !== null
|
||||
|
||||
let status: MembershipStatus = MembershipStatus.ACTIVE
|
||||
if (expireDate < now) {
|
||||
status = MembershipStatus.EXPIRED
|
||||
} else if (isTimeBased && remainingTimes <= 0) {
|
||||
} else if (isCountLimited && remainingTimes <= 0) {
|
||||
status = MembershipStatus.USED_UP
|
||||
}
|
||||
|
||||
const data = {
|
||||
cardTypeId: dto.cardTypeId,
|
||||
remainingTimes: dto.remainingTimes ?? null,
|
||||
remainingTimes,
|
||||
startDate: new Date(dto.startDate),
|
||||
expireDate: new Date(dto.expireDate),
|
||||
status,
|
||||
@@ -609,15 +609,21 @@ export class UserService {
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
const totalTimes = remainingTimes === null
|
||||
? null
|
||||
: existing.cardTypeId === dto.cardTypeId
|
||||
? Math.max(existing.totalTimes ?? 0, remainingTimes)
|
||||
: remainingTimes
|
||||
|
||||
return this.prisma.membership.update({
|
||||
where: { id: existing.id },
|
||||
data,
|
||||
data: { ...data, totalTimes },
|
||||
include: { cardType: true },
|
||||
})
|
||||
}
|
||||
|
||||
return this.prisma.membership.create({
|
||||
data: { userId, totalTimes: dto.remainingTimes ?? null, ...data },
|
||||
data: { userId, totalTimes: remainingTimes, ...data },
|
||||
include: { cardType: true },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -130,7 +130,7 @@ export function getMembershipRenewalHint(
|
||||
if (usable.length > 0) {
|
||||
const lowTimes = usable.filter(
|
||||
(m) =>
|
||||
m.cardType.type === CardTypeCategory.TIMES &&
|
||||
m.cardType.type !== CardTypeCategory.TRIAL &&
|
||||
m.remainingTimes !== null &&
|
||||
m.remainingTimes <= RENEWAL_TIMES_THRESHOLD,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user