feat: 下线支付功能; 月卡生效时间从第一次约课开始

This commit is contained in:
richarjiang
2026-10-30 20:51:21 +08:00
parent a192bbb186
commit 21558f841d
42 changed files with 490 additions and 196 deletions

View File

@@ -1,51 +1,40 @@
# 月卡周期与续费规则 # 月卡生效、购买与续卡规则
## 已确认规则 ## 当前规则(2026-10-30)
- 月卡(DURATION)每次购买生成独立会员卡周期,不向旧周期累加课时,不覆盖旧卡、订单和预约关联。 - 仅新购月卡(DURATION)及后台新开月卡采用首次课程核销生效。历史卡保留原起止时间,不批量修改历史余额、订单、预约或已签合同。
- 旧周期尚未到期时,新周期从同款卡已购周期的最晚到期时间开始。连续购买按支付确认顺序排期,每期额度独立。 - 每次购买生成独立会员卡,不累加到旧月卡。新卡保存购买时的 `activationDurationDays`,`startDate`、`expireDate` 均为 `null`,状态为 `SCHEDULED`(待首次核销)。等待期间不会自动生效或过期。
- 旧周期已结束时,新周期从支付确认时间开始。旧月卡即使账面还有 6 次,也不可继续使用,不会与新购 10 次合成 16 次。 - 用户预约、教练确认预约、取消预约、标记未出席及课时补录都不会启动计时。原有教练确认预约时扣次的规则保留;取消退回原卡。
- 次卡(TIMES)保留现有规则:有效期内续费累加次数、延长有效期;过期余次不结转。体验卡仍为独立新卡。 - 只有已确认预约核销为 `COMPLETED`,才在同一事务中首次设置月卡日期。起点取**核销当天中国时区 00:00**,该自然日计为第 1 天,截止时间为起点加购买时的有效天数,采用左闭右开区间。例如 10 月 31 日首次核销的 30 天卡,可用至 11 月 29 日结束,数据库截止时间为 11 月 30 日 00:00(中国时区)。
- 原有月卡沿用已经约定的有效期,不按开卡日倒推缩短,不批量改写历史余额。 - 后续课程核销、重复请求不重新计算或延长有效期。购卡、预约、确认和核销使用同一会员行锁,避免并发重复激活。
- 待首次核销月卡可以预约,仍须签署要求签署的合同并有可用课时。为了保证提前预约的课程落在首次生效后的有效期内,可预约课程不超过「今天中国时区零点 + 已购天数」。生效后按实际周期验证课程起止时间。
- 每张月卡的额度、预约、退款和到期独立,月卡余次不跨期结转。次卡、体验卡原有生效和续卡规则保留。
## 状态与展示 ## 页面与合同
新增 `SCHEDULED`(待生效)状态,数据库迁移只在原枚举末尾追加值,不改写现有记录。 - 月卡详情展示「第一次上课核销,才开始计时」,明确自然日、预约不计时和查询位置。使用说明中可直接查看《会员卡服务合同》范本。
- 「我的会员卡」对新卡显示「首次预约课程核销当天生效」及已购天数,可直接预约,不提供手动提前启用。
- 预约选卡、馆主代约、会员详情和编辑页均支持日期待定,避免显示虚假日期或将待核销卡误判过期。
- 新月卡合同模板 `2026-10-v3` 的会员卡信息与第二条说明首次核销起算,未激活时保留日期待定,不用支付或合同创建时间代替。合同购买天数采用会员卡快照。
- 原有有确定日期的月卡周期、自动到期/生效和提前启用规则继续兼容。已签合同内容与签名摘要保留。
读取会员权益时,依据服务端时间实时判断生效、到期和可用次数,不依赖凌晨任务。到期的限次月卡返回 0 次可用权益;原始账面余额留在旧记录中用于历史核对。待生效额度不会计入当前余额。 ## 在线支付前端入口
会员页分为「正在使用」「下期待生效」「历史记录」。续费页显示本期剩余与下期起止时间/独立额度,提前续费提示「支付后排期待生效」。页面打开期间在生效和到期边界刷新展示。 - 会员卡详情底部改为「联系馆主」,使用微信原生 `button open-type="contact"` 打开小程序客服会话,可附带所咨询会员卡的页面卡片。接待方沿用小程序后台已有客服配置。
- 支付确认弹窗和下单操作由 `packages/app/src/config/features.ts` 的 `ONLINE_PAYMENT_ENABLED = false` 屏蔽,保留原前端支付流程以供恢复。
- 后端下单、支付回调、退款、订单、发卡与合同能力均保留;未来恢复支付时,新月卡仍遵循首次核销生效。
## 提前启用 ## 发布步骤
接口:`POST /membership/:id/activate`,要求登录及 `{ "confirmed": true }`,仅操作登录用户自己的周期。 1. 备份数据库,应用 `20261030090000_monthly_first_check_in` 迁移(日期允许为空,新增有效天数快照字段;不更新历史记录)。
2. 生成 Prisma Client,构建并配套发布 shared/server 及新版小程序;旧版小程序不支持日期待定,不应单独发布服务端。
3. 在微信小程序真机中验收「联系馆主」客服会话,并验证新月卡购买/后台开卡、预约、确认、取消、核销后的日期与新合同范本。
只能启用同款卡最早的待生效周期。本期必须已经用完次数;不限次月卡或仍有余次的周期不允许提前结束。 无新增环境变量。当前改动只完成本地实现与验证,未应用生产迁移、部署服务端或发布小程序。
启用前先检查本期未完成预约;如仍有未来待确认/已确认课程,需上完或取消后再启用。新周期从启用时刻开始,保留完整已购时长,后续待生效周期连续重新衔接。已经预约到新周期或后续周期的课程必须仍在调整后的范围内,否则拒绝操作并提示调整预约,不自动取消课程。 ## 本地验证
购卡排期、提前启用及预约写入使用同一会员的数据库行锁串行处理。启用事务只修改周期日期与状态,不改变订单和预约所关联的周期 ID。 - 服务端完整回归:39 个套件、627 项测试通过;包含首次核销、中国时区跨日、预约确认与取消不激活、重复核销、历史卡兼容及合同条款/有效天数快照。
- shared/server 构建、小程序类型检查、微信小程序生产构建通过;首页 10 项测试通过。
## 预约与扣次 - 390px 宽度下使用真实 Vue 页面及隔离模拟数据检查详情说明、联系馆主按钮和待首次核销月卡列表。截图见 [页面预览](screenshots/monthly-first-check-in-preview.png)。微信原生客服会话需真机验收。
- `pnpm lint` 因环境未安装脚本引用的 `eslint` 而无法运行,未为此修改依赖或锁文件。
月卡可提前预约已付款周期内的课程,但课程起止时间必须完全落在所选周期内。上课时间按场馆中国时区计算。学员预约、馆主代约及确认预约均验证周期,不能借用另一期额度。
仍由教练确认预约时扣次;未来周期被确认预约后保留待生效状态。取消后退回原周期,不转入其他新卡;已过期月卡的退回次数不会变为可用权益。
## 发布与兼容
1. 备份生产数据库和当前 server/shared 构建及 Prisma Client。
2. 应用 `20261018090000_scheduled_membership_periods`,生成对应 Prisma Client,再发布 shared/server。
3. 发布小程序新版本,才能显示独立周期、正确续卡预览及提前启用入口。旧版内置的续卡计算不会随服务端更新,应配套发布小程序。
没有新增环境变量,不需要清空或重建已有会员数据。新周期与历史记录共用原有 memberships、orders、bookings 关系,既有购买不会重新发卡。
验证包括月卡 6 + 新购 10 分期保存、过期与临界时刻、连续购买排期、提前启用确认与归属、已有预约保护、课程周期边界、支付确认发放新周期、次卡原逻辑,以及共享/服务端/小程序构建。
## 本次验证(2026-10-18)
- 当前工作区完整服务端测试通过:34 个套件、545 项测试。
- shared/server 构建、小程序类型检查和微信小程序生产构建通过。
- `pnpm lint` 无法执行:现有环境未安装脚本引用的 `eslint`;未为此调整依赖或锁文件。
- 使用真实 Vue 页面与模拟数据检查了 390px 宽度下的未过期续卡、已过期续卡、待生效周期列表、提前启用确认及成功后的日期/额度更新;未发起真实支付或修改真实会员权益。
- 本次月卡迁移及服务端改动已与器械课后评价一并部署生产;新版小程序尚未上传或发布。详见 [联合发布记录](combined-release-2026-10-18.md)。

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View File

@@ -44,12 +44,13 @@
<view class="card-item selected"> <view class="card-item selected">
<view class="card-info"> <view class="card-info">
<text class="card-name">{{ memberships[0].cardType.name }}</text> <text class="card-name">{{ memberships[0].cardType.name }}</text>
<text v-if="memberships[0].cardType.type === 'DURATION'" class="card-remain">周期 {{ formatChinaDate(memberships[0].startDate) }} 至 {{ formatChinaDate(memberships[0].expireDate) }}</text> <text v-if="memberships[0].startDate === null" class="card-remain">首次课程核销当天生效</text>
<text v-else-if="memberships[0].cardType.type === 'DURATION'" class="card-remain">周期 {{ formatChinaDate(memberships[0].startDate) }} 至 {{ formatChinaDate(memberships[0].expireDate) }}</text>
<text class="card-remain" v-if="memberships[0].remainingTimes !== null"> <text class="card-remain" v-if="memberships[0].remainingTimes !== null">
剩余 {{ memberships[0].remainingTimes }} 次 剩余 {{ memberships[0].remainingTimes }} 次
</text> </text>
<text class="card-remain" v-else> <text class="card-remain" v-else>
有效期至 {{ memberships[0].expireDate.slice(0, 10) }} {{ memberships[0].expireDate ? '有效期至 ' + formatChinaDate(memberships[0].expireDate) : '生效后 ' + (memberships[0].activationDurationDays ?? memberships[0].cardType.durationDays) + ' 天内不限次' }}
</text> </text>
</view> </view>
<view class="check-mark"> <view class="check-mark">
@@ -69,12 +70,13 @@
> >
<view class="card-info"> <view class="card-info">
<text class="card-name">{{ m.cardType.name }}</text> <text class="card-name">{{ m.cardType.name }}</text>
<text v-if="m.cardType.type === 'DURATION'" class="card-remain">周期 {{ formatChinaDate(m.startDate) }} 至 {{ formatChinaDate(m.expireDate) }}</text> <text v-if="m.startDate === null" class="card-remain">首次课程核销当天生效</text>
<text v-else-if="m.cardType.type === 'DURATION'" class="card-remain">周期 {{ formatChinaDate(m.startDate) }} 至 {{ formatChinaDate(m.expireDate) }}</text>
<text class="card-remain" v-if="m.remainingTimes !== null"> <text class="card-remain" v-if="m.remainingTimes !== null">
剩余 {{ m.remainingTimes }} 次 剩余 {{ m.remainingTimes }} 次
</text> </text>
<text class="card-remain" v-else> <text class="card-remain" v-else>
有效期至 {{ m.expireDate.slice(0, 10) }} {{ m.expireDate ? '有效期至 ' + formatChinaDate(m.expireDate) : '生效后 ' + (m.activationDurationDays ?? m.cardType.durationDays) + ' 天内不限次' }}
</text> </text>
</view> </view>
<view class="check-mark" v-if="selectedMembershipId === m.id"> <view class="check-mark" v-if="selectedMembershipId === m.id">

View File

@@ -13,7 +13,7 @@
</view> </view>
<view v-if="!unlimited && total !== null && total > 0" class="usage-track" :aria-label="`已用${used}次,共${total}次`"><view class="usage-fill" :style="{ width: `${progress}%` }" /></view> <view v-if="!unlimited && total !== null && total > 0" class="usage-track" :aria-label="`已用${used}次,共${total}次`"><view class="usage-fill" :style="{ width: `${progress}%` }" /></view>
<view v-else class="pass-rule" /> <view v-else class="pass-rule" />
<view class="pass-dates"><text v-if="!compact">{{ membership.startDate.slice(0, 10).replace(/-/g, '.') }} 起</text><text :class="{ 'expiry-soon': days <= 7 }">{{ membership.expireDate.slice(0, 10).replace(/-/g, '.') }} 到期</text></view> <view class="pass-dates"><text v-if="!compact">{{ formatChinaDate(membership.startDate).replace(/-/g, '.') }} 起</text><text :class="{ 'expiry-soon': days <= 7 }">{{ formatDateTimeFull(membership.expireDate) }} 到期</text></view>
<slot /> <slot />
</view> </view>
</template> </template>
@@ -21,14 +21,14 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { CardTypeCategory, type MembershipWithCardType } from '@mp-pilates/shared' import { CardTypeCategory, type MembershipWithCardType } from '@mp-pilates/shared'
import { getCardTypeLabel, getMembershipTotalTimes, getMembershipUsedTimes } from '../utils/format' import { getCardTypeLabel, formatChinaDate, formatDateTimeFull, getMembershipTotalTimes, getMembershipUsedTimes } from '../utils/format'
const props = defineProps<{ membership: MembershipWithCardType; compact?: boolean; now: number }>() const props = defineProps<{ membership: MembershipWithCardType; compact?: boolean; now: number }>()
const unlimited = computed(() => props.membership.remainingTimes === null) const unlimited = computed(() => props.membership.remainingTimes === null)
const total = computed(() => getMembershipTotalTimes(props.membership)) const total = computed(() => getMembershipTotalTimes(props.membership))
const used = computed(() => getMembershipUsedTimes(props.membership)) const used = computed(() => getMembershipUsedTimes(props.membership))
const progress = computed(() => total.value && total.value > 0 ? Math.min(100, Math.max(0, used.value / total.value * 100)) : 0) const progress = computed(() => total.value && total.value > 0 ? Math.min(100, Math.max(0, used.value / total.value * 100)) : 0)
const days = computed(() => Math.max(0, Math.ceil((new Date(props.membership.expireDate).getTime() - props.now) / 86400000))) const days = computed(() => Math.max(0, Math.ceil((new Date(props.membership.expireDate ?? props.now).getTime() - props.now) / 86400000)))
const tone = computed(() => props.membership.cardType.type === CardTypeCategory.DURATION ? 'pass--sage' : props.membership.cardType.type === CardTypeCategory.TRIAL ? 'pass--clay' : 'pass--sand') const tone = computed(() => props.membership.cardType.type === CardTypeCategory.DURATION ? 'pass--sage' : props.membership.cardType.type === CardTypeCategory.TRIAL ? 'pass--clay' : 'pass--sand')
</script> </script>

View File

@@ -31,6 +31,11 @@
</view> </view>
</template> </template>
<view v-else-if="hasAwaitingCheckIn" class="entry-pill pill-active" role="button" hover-class="entry-pressed" @tap="handleBooking">
<text class="pill-label">月卡待首次核销 · 可预约课程</text>
<view class="pill-action action-book"><text class="pill-action-text">预约课程</text></view>
</view>
<view v-else-if="hasScheduledMembership" class="entry-pill pill-expired" role="button" hover-class="entry-pressed" @tap="handleMemberships"> <view v-else-if="hasScheduledMembership" class="entry-pill pill-expired" role="button" hover-class="entry-pressed" @tap="handleMemberships">
<text class="pill-label">会员卡尚未生效</text> <text class="pill-label">会员卡尚未生效</text>
<view class="pill-action"><text class="pill-action-text">查看会员卡</text></view> <view class="pill-action"><text class="pill-action-text">查看会员卡</text></view>
@@ -54,7 +59,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { getMembershipEffectiveState, getMembershipRenewalHint, MembershipStatus } from '@mp-pilates/shared' import { getMembershipEffectiveState, getMembershipRenewalHint, isMembershipAwaitingCheckIn, MembershipStatus } from '@mp-pilates/shared'
import { useUserStore } from '../stores/user' import { useUserStore } from '../stores/user'
import { getHomeMembershipDays } from '../utils/home' import { getHomeMembershipDays } from '../utils/home'
@@ -70,6 +75,7 @@ const activeMemberships = computed(() => userStore.memberships.filter(membership
&& (membership.remainingTimes === null || membership.remainingTimes > 0), && (membership.remainingTimes === null || membership.remainingTimes > 0),
)) ))
const renewalHint = computed(() => getMembershipRenewalHint(userStore.memberships, new Date(props.now))) const renewalHint = computed(() => getMembershipRenewalHint(userStore.memberships, new Date(props.now)))
const hasAwaitingCheckIn = computed(() => userStore.memberships.some(membership => isMembershipAwaitingCheckIn(membership) && (membership.remainingTimes === null || membership.remainingTimes > 0)))
const hasScheduledMembership = computed(() => userStore.memberships.some(membership => const hasScheduledMembership = computed(() => userStore.memberships.some(membership =>
getMembershipEffectiveState(membership, new Date(props.now)).status === MembershipStatus.SCHEDULED, getMembershipEffectiveState(membership, new Date(props.now)).status === MembershipStatus.SCHEDULED,
)) ))

View File

@@ -141,8 +141,8 @@ const cardProgress = computed(() => activeMemberships.value.map(m => {
label: total && total > 0 ? `已用 ${used}/${total} 次 · 余 ${m.remainingTimes}` : `剩余 ${m.remainingTimes} 次`, label: total && total > 0 ? `已用 ${used}/${total} 次 · 余 ${m.remainingTimes}` : `剩余 ${m.remainingTimes} 次`,
percent: total && total > 0 ? Math.max(0, Math.min(100, used / total * 100)) : null } percent: total && total > 0 ? Math.max(0, Math.min(100, used / total * 100)) : null }
} }
const start = new Date(m.startDate).getTime() const start = new Date(m.startDate ?? props.now).getTime()
const end = new Date(m.expireDate).getTime() const end = new Date(m.expireDate ?? props.now).getTime()
const remainingDays = Math.max(0, Math.ceil((end - props.now) / 86400000)) const remainingDays = Math.max(0, Math.ceil((end - props.now) / 86400000))
return { id: m.id, name: m.cardType.name, return { id: m.id, name: m.cardType.name,
label: `不限次 · 有效期剩 ${remainingDays} 天`, label: `不限次 · 有效期剩 ${remainingDays} 天`,

View File

@@ -0,0 +1,2 @@
// Restore this flag to re-enable the existing online purchase flow.
export const ONLINE_PAYMENT_ENABLED = false

View File

@@ -18,7 +18,7 @@
<view v-for="item in legacyExpanded ? candidates : []" :key="item.membershipId" class="legacy-item"> <view v-for="item in legacyExpanded ? candidates : []" :key="item.membershipId" class="legacy-item">
<view class="legacy-info"> <view class="legacy-info">
<text class="legacy-name">{{ item.user.nickname || '未命名学员' }}{{ item.user.phone ? ' · ' + item.user.phone : '' }}</text> <text class="legacy-name">{{ item.user.nickname || '未命名学员' }}{{ item.user.phone ? ' · ' + item.user.phone : '' }}</text>
<text class="legacy-meta">{{ item.cardName }} · {{ item.remainingTimes === null ? '不限次' : '剩余 ' + item.remainingTimes + ' 次' }} · 至 {{ formatChinaDate(item.expireDate) }}</text> <text class="legacy-meta">{{ item.cardName }} · {{ item.remainingTimes === null ? '不限次' : '剩余 ' + item.remainingTimes + ' 次' }} · {{ item.expireDate ? '至 ' + formatChinaDate(item.expireDate) : '首次课程核销当天生效' }}</text>
</view> </view>
<input v-model="amounts[item.membershipId]" class="legacy-input" type="digit" placeholder="实付(元)" /> <input v-model="amounts[item.membershipId]" class="legacy-input" type="digit" placeholder="实付(元)" />
<button class="legacy-button" :disabled="creating === item.membershipId" @tap="createLegacy(item)">发起</button> <button class="legacy-button" :disabled="creating === item.membershipId" @tap="createLegacy(item)">发起</button>

View File

@@ -29,7 +29,8 @@
<text class="card-pill-meta"> <text class="card-pill-meta">
{{ card.remainingTimes === null ? '有效期内不限次' : `剩 ${card.remainingTimes} 次` }} {{ card.remainingTimes === null ? '有效期内不限次' : `剩 ${card.remainingTimes} 次` }}
</text> </text>
<text v-if="card.cardType.type === 'DURATION'" class="card-pill-meta">{{ formatDate(card.startDate) }} 至 {{ formatDate(card.expireDate) }}</text> <text v-if="card.startDate === null" class="card-pill-meta">首次课程核销当天生效</text>
<text v-else-if="card.cardType.type === 'DURATION'" class="card-pill-meta">{{ formatDate(card.startDate) }} 至 {{ formatDate(card.expireDate) }}</text>
</view> </view>
</view> </view>
</scroll-view> </scroll-view>
@@ -137,7 +138,7 @@ const usableMemberships = computed(() =>
(detail.value?.memberships ?? []).filter((item) => (detail.value?.memberships ?? []).filter((item) =>
(item.status === MembershipStatus.ACTIVE || item.status === MembershipStatus.SCHEDULED) (item.status === MembershipStatus.ACTIVE || item.status === MembershipStatus.SCHEDULED)
&& (item.remainingTimes === null || item.remainingTimes > 0) && (item.remainingTimes === null || item.remainingTimes > 0)
&& new Date(item.expireDate) > new Date(), && (item.expireDate === null || new Date(item.expireDate) > new Date()),
), ),
) )

View File

@@ -105,8 +105,8 @@
</text> </text>
</view> </view>
<view class="mship-dates"> <view class="mship-dates">
<text>{{ formatDate(card.startDate) }} 起</text> <text v-if="card.startDate === null">首次课程核销当天生效</text><text v-else>{{ formatDate(card.startDate) }} 起</text>
<text>{{ formatDate(card.expireDate) }} 止</text> <text v-if="card.expireDate">{{ formatDate(card.expireDate) }} 止</text>
</view> </view>
</view> </view>
</view> </view>
@@ -189,7 +189,7 @@ const canArrange = computed(() => (detail.value?.memberships ?? []).some(isArran
function isArrangableMembership(membership: MembershipWithCardType): boolean { function isArrangableMembership(membership: MembershipWithCardType): boolean {
if (membership.status !== MembershipStatus.ACTIVE && membership.status !== MembershipStatus.SCHEDULED) return false if (membership.status !== MembershipStatus.ACTIVE && membership.status !== MembershipStatus.SCHEDULED) return false
if (membership.remainingTimes !== null && membership.remainingTimes <= 0) return false if (membership.remainingTimes !== null && membership.remainingTimes <= 0) return false
return new Date(membership.expireDate) > new Date() return membership.expireDate === null || new Date(membership.expireDate) > new Date()
} }
function membershipStatusLabel(status: string): string { function membershipStatusLabel(status: string): string {

View File

@@ -67,7 +67,8 @@
/> />
</view> </view>
<view class="field"> <view v-if="awaitingFirstCheckIn" class="field"><text class="field-label">生效规则</text><text>首次预约课程核销当天生效,有效期 {{ editingMembership?.activationDurationDays ?? selectedCardType?.durationDays }} 个自然日。</text></view>
<view v-if="!awaitingFirstCheckIn" class="field">
<text class="field-label">开始日期</text> <text class="field-label">开始日期</text>
<picker class="field-picker" mode="date" :value="editForm.startDate" @change="onStartDateChange"> <picker class="field-picker" mode="date" :value="editForm.startDate" @change="onStartDateChange">
<view class="picker-inner"> <view class="picker-inner">
@@ -77,7 +78,7 @@
</picker> </picker>
</view> </view>
<view class="field"> <view v-if="!awaitingFirstCheckIn" class="field">
<text class="field-label">到期日期</text> <text class="field-label">到期日期</text>
<picker class="field-picker" mode="date" :value="editForm.expireDate" @change="onExpireDateChange"> <picker class="field-picker" mode="date" :value="editForm.expireDate" @change="onExpireDateChange">
<view class="picker-inner"> <view class="picker-inner">
@@ -139,6 +140,7 @@ const editForm = ref({
const existingMemberships = computed(() => detail.value?.memberships ?? []) const existingMemberships = computed(() => detail.value?.memberships ?? [])
const editingMembership = computed(() => existingMemberships.value[membershipIndex.value] ?? null) const editingMembership = computed(() => existingMemberships.value[membershipIndex.value] ?? null)
const awaitingFirstCheckIn = computed(() => selectedCardType.value?.type === 'DURATION' && (!editingMembership.value || editingMembership.value.startDate === null))
const membershipPickerLabels = computed(() => const membershipPickerLabels = computed(() =>
existingMemberships.value.map((item) => `${item.cardType.name} · ${item.status}`), existingMemberships.value.map((item) => `${item.cardType.name} · ${item.status}`),
@@ -170,8 +172,8 @@ function applyMembership(membership: MembershipWithCardType | null) {
cardTypeIndex: idx >= 0 ? idx : 0, cardTypeIndex: idx >= 0 ? idx : 0,
cardTypeId: membership.cardTypeId, cardTypeId: membership.cardTypeId,
remainingTimes: membership.remainingTimes, remainingTimes: membership.remainingTimes,
startDate: membership.startDate.slice(0, 10), startDate: membership.startDate?.slice(0, 10) ?? formatDateLocal(new Date()),
expireDate: membership.expireDate.slice(0, 10), expireDate: membership.expireDate?.slice(0, 10) ?? calculateExpireDate(formatDateLocal(new Date()), membership.activationDurationDays ?? membership.cardType.durationDays),
manuallyEditedExpire: false, manuallyEditedExpire: false,
} }
return return

View File

@@ -81,7 +81,7 @@ const cardIndex = ref(0)
const pending = ref<CreateLessonSupplementDto | null>(null) const pending = ref<CreateLessonSupplementDto | null>(null)
const storageKey = computed(() => `lesson-supplement-pending:${userId.value}`) const storageKey = computed(() => `lesson-supplement-pending:${userId.value}`)
const cards = computed(() => (detail.value?.memberships ?? []).filter(c => c.remainingTimes !== null && c.remainingTimes > 0)) const cards = computed(() => (detail.value?.memberships ?? []).filter(c => c.remainingTimes !== null && c.remainingTimes > 0))
const cardLabels = computed(() => cards.value.map(c => `${c.cardType.name} · 余 ${c.remainingTimes} 次${new Date(c.expireDate) <= new Date() || c.status === 'EXPIRED' ? ' · 已过期' : ''}`)) const cardLabels = computed(() => cards.value.map(c => `${c.cardType.name} · 余 ${c.remainingTimes} 次${(c.expireDate !== null && new Date(c.expireDate) <= new Date()) || c.status === 'EXPIRED' ? ' · 已过期' : ''}`))
const selectedCard = computed(() => cards.value[cardIndex.value]) const selectedCard = computed(() => cards.value[cardIndex.value])
const count = computed(() => Number(quantity.value)) const count = computed(() => Number(quantity.value))
const validQuantity = computed(() => /^\d{1,3}$/.test(quantity.value) && count.value >= 1 && count.value <= 999) const validQuantity = computed(() => /^\d{1,3}$/.test(quantity.value) && count.value >= 1 && count.value <= 999)

View File

@@ -244,9 +244,13 @@
</view> </view>
</view> </view>
<view v-if="cardData.type === CardTypeCategory.DURATION" class="notice-card">
<view class="section-heading"><text class="section-kicker">START WITH YOUR FIRST CLASS</text><text class="section-title">第一次上课核销,才开始计时</text></view>
<text class="notice-text">购买后可预约课程,月卡从首次预约课程核销当天起生效,按中国时区计算 {{ cardData.durationDays }} 个自然日。仅预约、确认预约、取消或未出席均不会开始计时,具体起止日期可在「我的会员卡」查看。</text>
</view>
<view class="notice-card"> <view class="notice-card">
<view class="section-heading"> <view class="section-heading">
<text class="section-kicker">BEFORE YOU PAY</text> <text class="section-kicker">BEFORE YOU BUY</text>
<text class="section-title">{{ isRenewal ? '续卡说明' : '使用与退款说明' }}</text> <text class="section-title">{{ isRenewal ? '续卡说明' : '使用与退款说明' }}</text>
</view> </view>
<view <view
@@ -257,16 +261,28 @@
<text class="notice-number">0{{ index + 1 }}</text> <text class="notice-number">0{{ index + 1 }}</text>
<text class="notice-text">{{ note }}</text> <text class="notice-text">{{ note }}</text>
</view> </view>
<button v-if="contractRequired" class="contract-preview-button" @tap="previewContract">查看《会员卡服务合同》 ›</button>
</view> </view>
</view> </view>
<view class="bottom-bar" :class="{ 'bottom-bar--renew': isRenewal }"> <view class="bottom-bar" :class="{ 'bottom-bar--renew': isRenewal }">
<view class="price-summary"> <view class="price-summary">
<text class="price-summary-label">{{ isRenewal ? '续卡金额' : '实付金额' }}</text> <text class="price-summary-label">{{ isRenewal ? '续卡价格' : '会员卡价格' }}</text>
<text class="price-summary-value">¥{{ formatPrice(invite.price(cardData.price)) }}</text> <text class="price-summary-value">¥{{ formatPrice(invite.price(cardData.price)) }}</text>
<text class="price-summary-hint">{{ bottomPriceHint }}</text> <text class="price-summary-hint">{{ bottomPriceHint }}</text>
</view> </view>
<button <button
v-if="!ONLINE_PAYMENT_ENABLED"
class="buy-btn"
:class="{ 'buy-btn--renew': isRenewal }"
open-type="contact"
:show-message-card="true"
:send-message-title="cardData.name + ' · 咨询购买'"
:send-message-path="'/pages/card/detail?id=' + cardData.id"
@error="handleContactError"
>联系馆主</button>
<button
v-else
class="buy-btn" class="buy-btn"
:class="{ 'buy-btn--busy': isPurchaseBusy, 'buy-btn--renew': isRenewal }" :class="{ 'buy-btn--busy': isPurchaseBusy, 'buy-btn--renew': isRenewal }"
:disabled="isPurchaseBusy" :disabled="isPurchaseBusy"
@@ -278,7 +294,7 @@
</template> </template>
<view <view
v-if="purchaseConfirmVisible && card" v-if="ONLINE_PAYMENT_ENABLED && purchaseConfirmVisible && card"
class="purchase-sheet-layer" class="purchase-sheet-layer"
@tap="closePurchaseConfirmation" @tap="closePurchaseConfirmation"
@touchmove.stop.prevent @touchmove.stop.prevent
@@ -354,6 +370,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ONLINE_PAYMENT_ENABLED } from '../../config/features'
import { useInviteStore } from '../../stores/invite' import { useInviteStore } from '../../stores/invite'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { onLoad, onShow, onHide, onShareAppMessage } from '@dcloudio/uni-app' import { onLoad, onShow, onHide, onShareAppMessage } from '@dcloudio/uni-app'
@@ -465,8 +482,19 @@ const cardData = computed<CardType>(() => card.value as CardType)
const contractRequired = computed(() => !!card.value && requiresMembershipContract(card.value)) const contractRequired = computed(() => !!card.value && requiresMembershipContract(card.value))
const contractAccepted = ref(false) const contractAccepted = ref(false)
function previewContract() { async function previewContract() {
if (!card.value) return if (!card.value) return
if (!userStore.loggedIn) {
const confirmation = await uni.showModal({ title: '登录后查看合同', content: '登录后可查看该会员卡的服务合同范本。', confirmText: '去登录' })
if (!confirmation.confirm) return
try {
const result = await userStore.loginWithSetup()
if (result.isNewUser) return
} catch (error) {
uni.showToast({ title: getErrorMessage(error, '登录失败,请重试'), icon: 'none' })
return
}
}
uni.navigateTo({ url: `${CONTRACT_ROUTES.DETAIL}?previewCardTypeId=${card.value.id}` }) uni.navigateTo({ url: `${CONTRACT_ROUTES.DETAIL}?previewCardTypeId=${card.value.id}` })
} }
@@ -507,7 +535,7 @@ function syncRenewalClock() {
stopRenewalClock() stopRenewalClock()
renewalNow.value = Date.now() renewalNow.value = Date.now()
if (!renewalPageVisible) return if (!renewalPageVisible) return
const expires = renewalTarget.value ? new Date(renewalTarget.value.expireDate).getTime() : 0 const expires = renewalTarget.value?.expireDate ? new Date(renewalTarget.value.expireDate).getTime() : 0
if (expires > renewalNow.value) { if (expires > renewalNow.value) {
const remaining = expires - renewalNow.value const remaining = expires - renewalNow.value
const nextDayBoundary = remaining - (Math.ceil(remaining / 86_400_000) - 1) * 86_400_000 const nextDayBoundary = remaining - (Math.ceil(remaining / 86_400_000) - 1) * 86_400_000
@@ -525,7 +553,8 @@ const grantPreview = computed(() => {
}) })
}) })
function formatStudioDate(value: Date | string): string { function formatStudioDate(value: Date | string | null): string {
if (value === null) return '首次核销后确定'
const date = typeof value === 'string' ? new Date(value) : value const date = typeof value === 'string' ? new Date(value) : value
const year = date.getFullYear() const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0') const month = String(date.getMonth() + 1).padStart(2, '0')
@@ -537,7 +566,7 @@ const renewalPreview = computed(() => {
const grant = grantPreview.value const grant = grantPreview.value
const currentCard = card.value const currentCard = card.value
const now = new Date(renewalNow.value) const now = new Date(renewalNow.value)
const current = userStore.memberships.filter(m => m.cardTypeId === currentCard?.id && new Date(m.startDate) <= now && !isMembershipExpired(m, now)).sort((a,b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime())[0] const current = userStore.memberships.filter(m => m.cardTypeId === currentCard?.id && m.startDate !== null && new Date(m.startDate) <= now && !isMembershipExpired(m, now)).sort((a,b) => new Date(b.startDate ?? b.createdAt).getTime() - new Date(a.startDate ?? a.createdAt).getTime())[0]
const target = currentCard?.type === CardTypeCategory.DURATION ? current || renewalTarget.value : renewalTarget.value const target = currentCard?.type === CardTypeCategory.DURATION ? current || renewalTarget.value : renewalTarget.value
if (!target || !grant || !currentCard) return null if (!target || !grant || !currentCard) return null
@@ -563,16 +592,16 @@ const renewalPreview = computed(() => {
: String(currentCard.durationDays), : String(currentCard.durationDays),
highlightUnit: isTimes ? '次剩余' : '天有效', highlightUnit: isTimes ? '次剩余' : '天有效',
currentExpire: formatStudioDate(target.expireDate), currentExpire: formatStudioDate(target.expireDate),
afterStart: isTimes ? '' : formatStudioDate(grant.startDate) + ' 生效', afterStart: isTimes ? '' : '首次预约课程核销当天生效',
afterExpire: formatStudioDate(grant.expireDate) + (isTimes ? '' : ' 到期'), afterExpire: isTimes ? formatStudioDate(grant.expireDate) : '自首次核销当天起 ' + currentCard.durationDays + ' 个自然日',
currentAllowance: !isTimes && target.remainingTimes !== null currentAllowance: !isTimes && target.remainingTimes !== null
? awaitingPeriod ? '当前无生效周期' : isExpired ? '原周期余次已失效' : '本周期剩余 ' + String(effective.remainingTimes) + ' 次' : '', ? awaitingPeriod ? '当前无生效周期' : isExpired ? '原周期余次已失效' : '本周期剩余 ' + String(effective.remainingTimes) + ' 次' : '',
afterAllowance: !isTimes ? (grant.startDate > now ? '下期待生效 · ' : '新周期 · ') + (grant.remainingTimes === null ? '不限次' : String(grant.remainingTimes) + ' 次') : '', afterAllowance: !isTimes ? '新周期 · ' + (grant.remainingTimes === null ? '不限次' : String(grant.remainingTimes) + ' 次') : '',
addedLabel: isTimes addedLabel: isTimes
? '本次预计续入 +' + String(currentCard.totalTimes ?? 0) + ' 次 · +' + String(currentCard.durationDays) + ' 天' ? '本次预计续入 +' + String(currentCard.totalTimes ?? 0) + ' 次 · +' + String(currentCard.durationDays) + ' 天'
: '本次月卡' + (currentCard.totalTimes !== null ? '包含 ' + String(currentCard.totalTimes) + ' 次 · ' : ' · ') + String(currentCard.durationDays) + ' 天有效', : '本次月卡' + (currentCard.totalTimes !== null ? '包含 ' + String(currentCard.totalTimes) + ' 次 · ' : ' · ') + String(currentCard.durationDays) + ' 天有效',
forfeitNote: !isTimes forfeitNote: !isTimes
? grant.startDate > now ? '本期权益保留至到期,下期单独生效;月卡余次不跨期累计。' : '原周期已结束,剩余课时不结转;新周期按本次购买额度起算。' ? '每张新购月卡独立计算,从该卡首次预约课程核销当天起生效,余次不跨期累计。'
: isExpired && grant.remainingTimes !== null : isExpired && grant.remainingTimes !== null
? '到期余次不结转,按新购 ' + String(currentCard.totalTimes) + ' 次起算' ? '到期余次不结转,按新购 ' + String(currentCard.totalTimes) + ' 次起算'
: '', : '',
@@ -592,15 +621,12 @@ const purchaseNotes = computed(() => {
if (!card.value) return [] if (!card.value) return []
const notes: string[] = [] const notes: string[] = []
if (renewalTarget.value && isMembershipExpired(renewalTarget.value, new Date(renewalNow.value))) { if (card.value.type === CardTypeCategory.DURATION) {
notes.push('旧卡已到期,原周期剩余课时失效;支付确认后按新购权益重新生效,有效期为 ' + String(card.value.durationDays) + ' 天。') notes.push('购买后可预约课程;月卡从首次预约课程核销当天起生效,按中国时区计算 ' + card.value.durationDays + ' 个自然日。预约和确认预约不触发计时。')
} else if (isRenewal.value && card.value.type === CardTypeCategory.DURATION) {
notes.push('提前续费将生成独立的下期月卡,本期权益保留至到期,未用完的课时不结转。')
notes.push('本期课时用完后,可在「我的会员卡」确认提前启用下一期;原周期随之结束。')
} else if (isRenewal.value) { } else if (isRenewal.value) {
notes.push('支付确认后,现有会员有效期预计再延长 ' + String(card.value.durationDays) + ' 天。') notes.push('购卡确认后的权益与有效期请联系馆主核对。')
} else { } else {
notes.push('支付确认后会员卡生效,有效期为 ' + String(card.value.durationDays) + ' 天。') notes.push('有效期为 ' + card.value.durationDays + ' 天,购卡及生效详情请联系馆主。')
} }
if (card.value.totalTimes === null) { if (card.value.totalTimes === null) {
@@ -610,18 +636,18 @@ const purchaseNotes = computed(() => {
} }
if (card.value.type === CardTypeCategory.DURATION) { if (card.value.type === CardTypeCategory.DURATION) {
notes.push('每期独立计算,未用课时到期失效;本期用完后可确认启用下一期。') notes.push('每张月卡独立计算,未用课时到期失效;新购月卡无需手动提前启用。')
} else { } else {
notes.push(card.value.totalTimes === null ? '到期后自动失效。' : '到期或课时用完后自动失效。') notes.push(card.value.totalTimes === null ? '到期后自动失效。' : '到期或课时用完后自动失效。')
} }
if (requiresMembershipContract(card.value)) { if (requiresMembershipContract(card.value)) {
notes.push('付款 7 日内未上课可全额退款;之后退卡,已上课时按单节标准课价扣除,不收手续费,详见合同。') notes.push('付款 7 日内未上课可全额退款;之后退卡,已上课时按单节标准课价扣除,不收手续费,详见合同。')
notes.push('支付成功后需在线签署《会员卡服务合同》,签署后即可预约课程。') notes.push('购卡后请按提示签署《会员卡服务合同》,签署后即可预约课程。')
} else if (card.value.totalTimes !== null) { } else if (card.value.totalTimes !== null) {
notes[notes.length - 1] = '到期或课时用完后自动失效,不可退款。' notes[notes.length - 1] = '到期或课时用完后自动失效,不可退款。'
} }
notes.push('支持微信支付,支付过程由微信安全保障。') notes.push(ONLINE_PAYMENT_ENABLED ? '支持微信支付,支付过程由微信安全保障。' : '点击「联系馆主」进入微信客服会话,咨询购买与续卡。')
return notes return notes
}) })
@@ -641,7 +667,7 @@ const buyButtonLabel = computed(() => {
}) })
const bottomPriceHint = computed(() => { const bottomPriceHint = computed(() => {
const effectiveHint = card.value?.type === CardTypeCategory.DURATION && grantPreview.value && grantPreview.value.startDate.getTime() > renewalNow.value ? '支付后排期待生效' : '支付确认后生效' const effectiveHint = card.value?.type === CardTypeCategory.DURATION ? '首次课程核销当天生效' : ONLINE_PAYMENT_ENABLED ? '支付确认后生效' : '联系馆主咨询购买'
if (savingsLabel.value) return savingsLabel.value + ' · ' + effectiveHint if (savingsLabel.value) return savingsLabel.value + ' · ' + effectiveHint
return effectiveHint return effectiveHint
}) })
@@ -811,8 +837,12 @@ function showReauthenticationPrompt() {
}) })
} }
function handleContactError() {
uni.showToast({ title: '暂时无法打开客服会话,请稍后重试', icon: 'none' })
}
async function handleBuy() { async function handleBuy() {
if (!card.value || isPurchaseBusy.value) return if (!ONLINE_PAYMENT_ENABLED || !card.value || isPurchaseBusy.value) return
if (!userStore.loggedIn) { if (!userStore.loggedIn) {
showLoginPrompt() showLoginPrompt()
return return
@@ -821,7 +851,7 @@ async function handleBuy() {
} }
async function preparePurchaseConfirmation() { async function preparePurchaseConfirmation() {
if (!card.value || isPurchaseBusy.value) return if (!ONLINE_PAYMENT_ENABLED || !card.value || isPurchaseBusy.value) return
purchasePreparing.value = true purchasePreparing.value = true
try { try {
await invite.refresh() await invite.refresh()
@@ -953,7 +983,7 @@ function goToMembership() {
} }
async function doPurchase() { async function doPurchase() {
if (!card.value || buying.value) return if (!ONLINE_PAYMENT_ENABLED || !card.value || buying.value) return
if (contractRequired.value && !contractAccepted.value) { if (contractRequired.value && !contractAccepted.value) {
uni.showToast({ title: '请先阅读并同意《会员卡服务合同》', icon: 'none' }) uni.showToast({ title: '请先阅读并同意《会员卡服务合同》', icon: 'none' })
return return
@@ -1038,6 +1068,7 @@ onUnmounted(() => {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.contract-preview-button { margin: 24rpx 0 0; padding: 0; background: transparent; color: #617d70; text-align: left; font-size: 24rpx; line-height: 1.8; &::after { border: 0; } }
.card-share-row { display: flex; justify-content: flex-end; margin: 8rpx 32rpx 0; } .card-share-row { display: flex; justify-content: flex-end; margin: 8rpx 32rpx 0; }
.card-share-button { display: flex; align-items: center; gap: 10rpx; margin: 0; padding: 12rpx 20rpx; min-height: 64rpx; line-height: 40rpx; background: transparent; color: #526e58; font-size: 23rpx; border-radius: 32rpx; &::after { border: none; } &:active { background: #eaf0e5; } } .card-share-button { display: flex; align-items: center; gap: 10rpx; margin: 0; padding: 12rpx 20rpx; min-height: 64rpx; line-height: 40rpx; background: transparent; color: #526e58; font-size: 23rpx; border-radius: 32rpx; &::after { border: none; } &:active { background: #eaf0e5; } }
.card-share-icon { font-size: 28rpx; } .card-share-icon { font-size: 28rpx; }

View File

@@ -12,7 +12,7 @@
<template v-else> <template v-else>
<view v-if="previewCardTypeId" class="status status--preview"> <view v-if="previewCardTypeId" class="status status--preview">
<text class="status__title">合同范本预览</text> <text class="status__title">合同范本预览</text>
<text class="status__copy">支付成功后将生成正式合同,填写姓名并手写签名后生效。金额、日期以支付确认后的正式合同为准。</text> <text class="status__copy">购卡后请核对正式合同,填写姓名并手写签名。月卡权益从首次预约课程核销当天起算,具体以正式合同与会员卡记录为准。</text>
</view> </view>
<view v-else-if="detail" class="status" :class="'status--' + detail.status.toLowerCase()"> <view v-else-if="detail" class="status" :class="'status--' + detail.status.toLowerCase()">
<text class="status__title">{{ CONTRACT_STATUS_LABEL[detail.status] }}</text> <text class="status__title">{{ CONTRACT_STATUS_LABEL[detail.status] }}</text>

View File

@@ -31,7 +31,7 @@
<text class="contract-card__meta">合同编号 {{ item.contractNo }}</text> <text class="contract-card__meta">合同编号 {{ item.contractNo }}</text>
<view class="contract-card__facts"> <view class="contract-card__facts">
<text>¥{{ formatPrice(item.amount) }}</text> <text>¥{{ formatPrice(item.amount) }}</text>
<text>有效期至 {{ formatChinaDate(item.expireDate) }}</text> <text>{{ item.expireDate ? '有效期至 ' + formatChinaDate(item.expireDate) : '首次课程核销当天生效' }}</text>
</view> </view>
<view class="contract-card__foot"> <view class="contract-card__foot">
<text v-if="item.status === MembershipContractStatus.SIGNED">签署于 {{ formatDateTimeFull(item.signedAt!) }}</text> <text v-if="item.status === MembershipContractStatus.SIGNED">签署于 {{ formatDateTimeFull(item.signedAt!) }}</text>

View File

@@ -22,7 +22,7 @@
<view v-if="step === 'identity'" class="panel"> <view v-if="step === 'identity'" class="panel">
<view class="card-summary"> <view class="card-summary">
<text class="card-summary__name">{{ detail.cardName }}</text> <text class="card-summary__name">{{ detail.cardName }}</text>
<text class="card-summary__meta">¥{{ formatPrice(detail.amount) }} · 有效期至 {{ formatChinaDate(detail.expireDate) }}</text> <text class="card-summary__meta">¥{{ formatPrice(detail.amount) }} · {{ detail.expireDate ? '有效期至 ' + formatChinaDate(detail.expireDate) : '首次课程核销当天生效' }}</text>
<text v-if="detail.source === MembershipContractSource.LEGACY" class="card-summary__tag">存量会员卡补签</text> <text v-if="detail.source === MembershipContractSource.LEGACY" class="card-summary__tag">存量会员卡补签</text>
</view> </view>
<text class="panel__title">确认签约人信息</text> <text class="panel__title">确认签约人信息</text>

View File

@@ -10,6 +10,7 @@
<view><text class="summary-value">{{ userStore.statsError ? '—' : userStore.stats?.monthBookings ?? '—' }}</text><text class="summary-label">本月上课 · 节</text></view> <view><text class="summary-value">{{ userStore.statsError ? '—' : userStore.stats?.monthBookings ?? '—' }}</text><text class="summary-label">本月上课 · 节</text></view>
<view><text class="summary-value">{{ remainingLabel }}</text><text class="summary-label">剩余课时{{ finiteBalance > 0 || !unlimitedCount ? ' · 次' : '' }}</text></view> <view><text class="summary-value">{{ remainingLabel }}</text><text class="summary-label">剩余课时{{ finiteBalance > 0 || !unlimitedCount ? ' · 次' : '' }}</text></view>
</view> </view>
<text v-if="pendingCheckInCount" class="summary-note">另有 {{ pendingCheckInCount }} 张月卡待首次核销,可预约课程。</text>
<text v-if="unlimitedCount && finiteBalance > 0 && !userStore.membershipsError" class="summary-note">另有 {{ unlimitedCount }} 张有效不限次卡,可在有效期内预约</text> <text v-if="unlimitedCount && finiteBalance > 0 && !userStore.membershipsError" class="summary-note">另有 {{ unlimitedCount }} 张有效不限次卡,可在有效期内预约</text>
<button v-if="userStore.statsError" class="summary-retry" @tap="userStore.fetchStats()">练习统计暂时无法更新,点击重试 ›</button> <button v-if="userStore.statsError" class="summary-retry" @tap="userStore.fetchStats()">练习统计暂时无法更新,点击重试 ›</button>
</view> </view>
@@ -45,12 +46,12 @@
</view> </view>
<view v-if="scheduledMemberships.length" class="group-section"> <view v-if="scheduledMemberships.length" class="group-section">
<view class="group-header"><text class="group-title">下期待生效</text><text class="group-count">{{ scheduledMemberships.length }} 个独立周期</text></view> <view class="group-header"><text class="group-title">待生效</text><text class="group-count">{{ scheduledMemberships.length }} 个独立周期</text></view>
<view v-for="m in scheduledMemberships" :key="m.id" class="mc mc--scheduled"> <view v-for="m in scheduledMemberships" :key="m.id" class="mc mc--scheduled">
<view class="mc-top"><view class="mc-name-area"><text class="mc-name">{{ m.cardType.name }}</text><text class="mc-type-text">{{ m.remainingTimes === null ? '有效期内不限次' : '周期额度剩余 ' + m.remainingTimes + ' 次' }}</text></view><text class="mc-status">待生效</text></view> <view class="mc-top"><view class="mc-name-area"><text class="mc-name">{{ m.cardType.name }}</text><text class="mc-type-text">{{ m.remainingTimes === null ? '有效期内不限次' : '周期额度剩余 ' + m.remainingTimes + ' 次' }}</text></view><text class="mc-status">待生效</text></view>
<view class="mc-history-bottom"><view class="mc-history-info"><text class="mc-date-label">{{ formatDateTimeFull(m.startDate) }} 生效</text><text class="mc-date-label">{{ formatDateTimeFull(m.expireDate) }} 到期</text></view><button v-if="canActivate(m)" class="mc-renew mc-renew--inactive" :disabled="!!activating" @tap="activate(m)">{{ activating === m.id ? '启用中…' : '提前启用' }}</button></view> <view class="mc-history-bottom"><view class="mc-history-info"><template v-if="isMembershipAwaitingCheckIn(m)"><text class="mc-date-label">首次预约课程核销当天生效</text><text class="mc-date-label">生效后 {{ m.activationDurationDays ?? m.cardType.durationDays }} 个自然日有效</text></template><template v-else><text class="mc-date-label">{{ formatDateTimeFull(m.startDate) }} 生效</text><text class="mc-date-label">{{ formatDateTimeFull(m.expireDate) }} 到期</text></template></view><button v-if="isMembershipAwaitingCheckIn(m) && m.remainingTimes !== 0" class="mc-renew" @tap="goBooking">预约课程</button><button v-if="canActivate(m)" class="mc-renew mc-renew--inactive" :disabled="!!activating" @tap="activate(m)">{{ activating === m.id ? '启用中…' : '提前启用' }}</button></view>
</view> </view>
<text class="usage-note">下期课时与本期分别计算,不计入当前可用余额。预约需落在对应周期内。</text> <text class="usage-note">新购月卡可先预约,首次课程核销当天开始计时;每张卡的课时与有效期独立计算。</text>
</view> </view>
<view v-if="inactiveMemberships.length" class="group-section"> <view v-if="inactiveMemberships.length" class="group-section">
@@ -70,7 +71,7 @@
<view class="mc-history-info"> <view class="mc-history-info">
<text v-if="m.cardType.type === CardTypeCategory.DURATION && m.status === MembershipStatus.EXPIRED" class="mc-date-label">原周期未用课时已失效</text> <text v-if="m.cardType.type === CardTypeCategory.DURATION && m.status === MembershipStatus.EXPIRED" class="mc-date-label">原周期未用课时已失效</text>
<text v-else-if="m.remainingTimes !== null" class="mc-date-label">剩余 {{ m.remainingTimes }} 次</text> <text v-else-if="m.remainingTimes !== null" class="mc-date-label">剩余 {{ m.remainingTimes }} 次</text>
<text class="mc-date-label">{{ m.expireDate.slice(0, 10) }} 到期</text> <text class="mc-date-label">{{ m.expireDate?.slice(0, 10) ?? '待确定' }} 到期</text>
</view> </view>
<button v-if="canRenewMembership(m)" class="mc-renew mc-renew--inactive" @tap="goRenew(m)">续同款</button> <button v-if="canRenewMembership(m)" class="mc-renew mc-renew--inactive" @tap="goRenew(m)">续同款</button>
</view> </view>
@@ -89,7 +90,7 @@
import { ref, computed, watch, onUnmounted } from 'vue' import { ref, computed, watch, onUnmounted } from 'vue'
import { onShow, onHide, onResize } from '@dcloudio/uni-app' import { onShow, onHide, onResize } from '@dcloudio/uni-app'
import type { MembershipWithCardType } from '@mp-pilates/shared' import type { MembershipWithCardType } from '@mp-pilates/shared'
import { MembershipStatus, CardTypeCategory, getMembershipEffectiveState } from '@mp-pilates/shared' import { MembershipStatus, CardTypeCategory, getMembershipEffectiveState, isMembershipAwaitingCheckIn } from '@mp-pilates/shared'
import { useUserStore } from '../../stores/user' import { useUserStore } from '../../stores/user'
import { getSystemLayout } from '../../utils/system' import { getSystemLayout } from '../../utils/system'
import { getCardTypeLabel, formatDateTimeFull } from '../../utils/format' import { getCardTypeLabel, formatDateTimeFull } from '../../utils/format'
@@ -118,14 +119,16 @@ function syncBoundaryClock() {
stopBoundaryClock() stopBoundaryClock()
membershipNow.value = Date.now() membershipNow.value = Date.now()
if (!pageVisible) return if (!pageVisible) return
const boundaries = userStore.memberships.flatMap(m => [new Date(m.startDate).getTime(), new Date(m.expireDate).getTime()]).filter(time => time > membershipNow.value) const boundaries = userStore.memberships.flatMap(m => [m.startDate, m.expireDate].filter((date): date is string => date !== null).map(date => new Date(date).getTime())).filter(time => time > membershipNow.value)
const delay = Math.min(60_000, ...boundaries.map(time => time - membershipNow.value + 1)) const delay = Math.min(60_000, ...boundaries.map(time => time - membershipNow.value + 1))
boundaryTimer = setTimeout(syncBoundaryClock, delay) boundaryTimer = setTimeout(syncBoundaryClock, delay)
} }
watch(() => userStore.memberships, syncBoundaryClock) watch(() => userStore.memberships, syncBoundaryClock)
const allMemberships = computed(() => userStore.memberships.map(m => ({ ...m, ...getMembershipEffectiveState(m, new Date(membershipNow.value)) }))) const allMemberships = computed(() => userStore.memberships.map(m => ({ ...m, ...getMembershipEffectiveState(m, new Date(membershipNow.value)) })))
const scheduledMemberships = computed(() => allMemberships.value.filter(m => m.status === MembershipStatus.SCHEDULED).sort((a,b) => new Date(a.startDate).getTime() - new Date(b.startDate).getTime())) const scheduledMemberships = computed(() => allMemberships.value.filter(m => m.status === MembershipStatus.SCHEDULED).sort((a,b) => new Date(a.startDate ?? a.createdAt).getTime() - new Date(b.startDate ?? b.createdAt).getTime()))
const pendingCheckInCount = computed(() => allMemberships.value.filter(isMembershipAwaitingCheckIn).length)
const activeMemberships = computed(() => const activeMemberships = computed(() =>
allMemberships.value.filter((m) => m.status === MembershipStatus.ACTIVE), allMemberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
@@ -190,7 +193,7 @@ function goRenew(m: MembershipWithCardType) {
} }
function canActivate(m: MembershipWithCardType): boolean { function canActivate(m: MembershipWithCardType): boolean {
return m.cardType.type === CardTypeCategory.DURATION && m.remainingTimes !== 0 return !isMembershipAwaitingCheckIn(m) && m.cardType.type === CardTypeCategory.DURATION && m.remainingTimes !== 0
&& scheduledMemberships.value.find(period => period.cardTypeId === m.cardTypeId)?.id === m.id && scheduledMemberships.value.find(period => period.cardTypeId === m.cardTypeId)?.id === m.id
&& !activeMemberships.value.some(period => period.cardTypeId === m.cardTypeId && period.remainingTimes !== 0) && !activeMemberships.value.some(period => period.cardTypeId === m.cardTypeId && period.remainingTimes !== 0)
} }

View File

@@ -13,14 +13,16 @@ export function formatPrice(cents: number): string {
} }
/** 中国自然日 YYYY-MM-DD,用于时间戳与 UTC 日期列展示 */ /** 中国自然日 YYYY-MM-DD,用于时间戳与 UTC 日期列展示 */
export function formatChinaDate(value: Date | string): string { export function formatChinaDate(value: Date | string | null): string {
if (value === null) return '首次核销后确定'
const time = new Date(value).getTime() const time = new Date(value).getTime()
if (!Number.isFinite(time)) return '' if (!Number.isFinite(time)) return ''
return new Date(time + 8 * 3600_000).toISOString().slice(0, 10) return new Date(time + 8 * 3600_000).toISOString().slice(0, 10)
} }
/** 格式化日期为 YYYY-MM-DD */ /** 格式化日期为 YYYY-MM-DD */
export function formatDate(date: Date | string): string { export function formatDate(date: Date | string | null): string {
if (date === null) return '首次核销后确定'
const d = typeof date === 'string' ? new Date(date) : date const d = typeof date === 'string' ? new Date(date) : date
const year = d.getFullYear() const year = d.getFullYear()
const month = String(d.getMonth() + 1).padStart(2, '0') const month = String(d.getMonth() + 1).padStart(2, '0')
@@ -139,7 +141,8 @@ export function getStockPercent(soldCount: number, totalStock: number): string {
} }
/** 格式化日期时间为 YYYY-MM-DD HH:mm */ /** 格式化日期时间为 YYYY-MM-DD HH:mm */
export function formatDateTimeFull(dateStr: string): string { export function formatDateTimeFull(dateStr: string | null): string {
if (dateStr === null) return '首次核销后确定'
const d = new Date(dateStr) const d = new Date(dateStr)
if (Number.isNaN(d.getTime())) return '-' if (Number.isNaN(d.getTime())) return '-'
const year = d.getFullYear() const year = d.getFullYear()

View File

@@ -43,7 +43,8 @@ export function getHomeBookingDate(value: string) {
} }
} }
export function getHomeMembershipDays(expireDate: string, now: Date) { export function getHomeMembershipDays(expireDate: string | null, now: Date) {
if (expireDate === null) return 0
const expiry = new Date(expireDate).getTime() const expiry = new Date(expireDate).getTime()
if (!Number.isFinite(expiry)) return 0 if (!Number.isFinite(expiry)) return 0
return Math.max(0, Math.ceil((expiry - now.getTime()) / DAY_MS)) return Math.max(0, Math.ceil((expiry - now.getTime()) / DAY_MS))

View File

@@ -0,0 +1,5 @@
-- Preserve all historical periods. New monthly cards have no dates until first check-in.
ALTER TABLE `memberships`
MODIFY `start_date` DATETIME(3) NULL,
MODIFY `expire_date` DATETIME(3) NULL,
ADD COLUMN `activation_duration_days` INTEGER NULL;

View File

@@ -163,8 +163,9 @@ model Membership {
cardTypeId String @map("card_type_id") cardTypeId String @map("card_type_id")
remainingTimes Int? @map("remaining_times") remainingTimes Int? @map("remaining_times")
totalTimes Int? @map("total_times") totalTimes Int? @map("total_times")
startDate DateTime @map("start_date") startDate DateTime? @map("start_date")
expireDate DateTime @map("expire_date") expireDate DateTime? @map("expire_date")
activationDurationDays Int? @map("activation_duration_days")
status MembershipStatus @default(ACTIVE) status MembershipStatus @default(ACTIVE)
createdAt DateTime @default(now()) @map("created_at") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")

View File

@@ -1,7 +1,7 @@
import { Test, TestingModule } from '@nestjs/testing' import { Test, TestingModule } from '@nestjs/testing'
import { JwtService } from '@nestjs/jwt' import { JwtService } from '@nestjs/jwt'
import { UnauthorizedException } from '@nestjs/common' import { UnauthorizedException } from '@nestjs/common'
import { MembershipStatus, UserRole } from '@mp-pilates/shared' import { MembershipStatus, UserRole, CardTypeCategory } from '@mp-pilates/shared'
import { ConfigService } from '@nestjs/config' import { ConfigService } from '@nestjs/config'
import { AuthService, RANDOM_FN_TOKEN } from '../auth.service' import { AuthService, RANDOM_FN_TOKEN } from '../auth.service'
import { WechatService } from '../wechat.service' import { WechatService } from '../wechat.service'
@@ -231,9 +231,10 @@ describe('AuthService', () => {
where: { where: {
userId: USER_ID, userId: USER_ID,
status: { in: [MembershipStatus.ACTIVE, MembershipStatus.SCHEDULED] }, status: { in: [MembershipStatus.ACTIVE, MembershipStatus.SCHEDULED] },
startDate: { lte: expect.any(Date) }, AND: [
expireDate: { gt: expect.any(Date) }, { OR: [{ startDate: { lte: expect.any(Date) }, expireDate: { gt: expect.any(Date) } }, { startDate: null, expireDate: null, cardType: { type: CardTypeCategory.DURATION } }] },
OR: [{ remainingTimes: { gt: 0 } }, { remainingTimes: null }], { OR: [{ remainingTimes: { gt: 0 } }, { remainingTimes: null }] },
],
}, },
}) })
expect(result.user.activeMembershipCount).toBe(2) expect(result.user.activeMembershipCount).toBe(2)

View File

@@ -277,6 +277,43 @@ describe('BookingService', () => {
expect(tx.booking.create).toHaveBeenCalledWith(expect.objectContaining({data:expect.objectContaining({membershipId:planned.id})})) expect(tx.booking.create).toHaveBeenCalledWith(expect.objectContaining({data:expect.objectContaining({membershipId:planned.id})}))
expect(tx.membership.update).not.toHaveBeenCalled() expect(tx.membership.update).not.toHaveBeenCalled()
}) })
it('keeps a new monthly card undated through reservation and confirmation', async () => {
jest.useFakeTimers().setSystemTime(new Date('2099-12-30T00:00:00Z'))
try {
const pending = { ...mockLimitedDurationMembership, startDate: null, expireDate: null, activationDurationDays: 30, status: MembershipStatus.SCHEDULED }
const tx = buildTxMock()
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
tx.booking.findUnique.mockResolvedValue(null)
tx.membership.findUnique.mockResolvedValue(pending)
tx.booking.create.mockResolvedValue({ ...mockConfirmedBooking, status: BookingStatus.PENDING_CONFIRMATION })
;(prisma.$transaction as jest.Mock).mockImplementation(fn => fn(tx))
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue({ ...mockConfirmedBooking, timeSlot: mockOpenSlot, membership: pending })
;(prisma.user.findMany as jest.Mock).mockResolvedValue([])
await service.createBooking(MOCK_USER_ID, { timeSlotId: MOCK_SLOT_ID, membershipId: pending.id })
expect(tx.membership.update).not.toHaveBeenCalled()
tx.booking.findUnique.mockResolvedValue({ ...mockConfirmedBooking, status: BookingStatus.PENDING_CONFIRMATION, membershipId: pending.id, timeSlot: mockOpenSlot, membership: pending })
tx.booking.update.mockResolvedValue(mockConfirmedBooking)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue(null)
studioService.getInfo.mockResolvedValue(mockStudioConfig as never)
await service.confirmBooking(MOCK_BOOKING_ID, MOCK_ADMIN_ID)
expect(tx.membership.update).toHaveBeenCalledWith({ where: { id: pending.id }, data: { remainingTimes: 4, status: MembershipStatus.SCHEDULED } })
expect(pending.startDate).toBeNull()
expect(pending.expireDate).toBeNull()
} finally { jest.useRealTimers() }
})
it('refunds cancellation to the pending card without setting activation dates', async () => {
const pending = { ...mockLimitedDurationMembership, startDate: null, expireDate: null, activationDurationDays: 30, status: MembershipStatus.SCHEDULED }
const tx = buildTxMock()
const booking = { ...mockConfirmedBooking, membershipId: pending.id, membership: pending, timeSlot: { ...mockOpenSlot, bookedCount: 1 } }
;(prisma.booking.findUnique as jest.Mock).mockResolvedValue(booking)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue(null)
;(prisma.$transaction as jest.Mock).mockImplementation(fn => fn(tx))
studioService.getInfo.mockResolvedValue(mockStudioConfig as never)
tx.booking.update.mockResolvedValue({ ...booking, status: BookingStatus.CANCELLED })
const result = await service.cancelBooking(MEMBER_ACTOR, MOCK_BOOKING_ID)
expect(result.refunded).toBe(true)
expect(tx.membership.update).toHaveBeenCalledWith({ where: { id: pending.id }, data: { remainingTimes: 6, status: MembershipStatus.SCHEDULED } })
})
it.each(['before','after'])('rejects a course %s the monthly period before writing a booking',async boundary=>{ it.each(['before','after'])('rejects a course %s the monthly period before writing a booking',async boundary=>{
const tx=buildTxMock() const tx=buildTxMock()
const membership=boundary==='before'?{...planned,startDate:new Date('2100-01-01T00:00:00Z')}:{...planned,expireDate:new Date('2099-12-31T00:00:00Z')} const membership=boundary==='before'?{...planned,startDate:new Date('2100-01-01T00:00:00Z')}:{...planned,expireDate:new Date('2099-12-31T00:00:00Z')}

View File

@@ -0,0 +1,96 @@
import { BadRequestException } from '@nestjs/common'
import { BookingStatus, CardTypeCategory, MembershipStatus, canUseMembershipForSlot, getMembershipEffectiveState, getMembershipRenewalHint } from '@mp-pilates/shared'
import { BookingService } from '../booking.service'
const checkInTime = new Date('2026-10-30T16:05:00Z') // Oct 31, 00:05 in China
const pending = () => ({
id: 'monthly', userId: 'member', cardTypeId: 'monthly-type',
remainingTimes: 9, totalTimes: 10, startDate: null, expireDate: null,
activationDurationDays: 30, status: MembershipStatus.SCHEDULED,
cardType: { type: CardTypeCategory.DURATION, durationDays: 7 }, // Later card edits must not shorten purchased validity.
})
function setup(overrides: Record<string, unknown> = {}) {
const membership = { ...pending(), ...overrides }
const booking = { id: 'booking', userId: 'member', membershipId: membership.id, status: BookingStatus.CONFIRMED, membership }
const tx = {
$queryRaw: jest.fn(),
booking: { findUnique: jest.fn().mockResolvedValue(booking), update: jest.fn().mockImplementation(async ({ data }) => Object.assign(booking, data)) },
membership: { update: jest.fn().mockImplementation(async ({ data }) => Object.assign(membership, data)) },
bookingStatusHistory: { create: jest.fn() },
}
const db = { $transaction: jest.fn(async (fn) => fn(tx)), booking: { findUnique: jest.fn().mockImplementation(async () => booking) } }
const service = new BookingService(db as never, {} as never, {} as never, {} as never, { recordQualifiedTrialBooking: jest.fn() } as never)
return { tx, membership, booking, service }
}
afterEach(() => jest.useRealTimers())
describe('Monthly activation on the first completed booking', () => {
beforeEach(() => jest.useFakeTimers().setSystemTime(checkInTime))
it.each([9, 0, null])('starts on the China-local check-in day, preserving the quota and purchased 30 days: %s', async remainingTimes => {
const { service, tx, membership } = setup({ remainingTimes })
await service.completeBooking('booking', 'owner')
expect(tx.$queryRaw.mock.invocationCallOrder[0]).toBeLessThan(tx.booking.findUnique.mock.invocationCallOrder[0])
expect(membership).toMatchObject({
startDate: new Date('2026-10-30T16:00:00Z'), expireDate: new Date('2026-11-29T16:00:00Z'),
remainingTimes, status: remainingTimes === 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE,
})
expect(tx.booking.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ completedAt: checkInTime }) }))
})
it('does not activate a no-show', async () => {
const { service, tx, membership } = setup()
await service.markNoShow('booking', 'owner')
expect(membership.startDate).toBeNull()
expect(tx.membership.update).not.toHaveBeenCalled()
})
it('does not reset validity on a later completed booking or change a legacy monthly card', async () => {
const dates = { startDate: new Date('2026-10-01T00:00:00Z'), expireDate: new Date('2026-12-01T00:00:00Z'), status: MembershipStatus.ACTIVE }
const { service, tx, membership } = setup(dates)
await service.completeBooking('booking', 'owner')
expect(tx.membership.update).not.toHaveBeenCalled()
expect(membership).toMatchObject(dates)
})
it('rejects a duplicate completion without extending the card again', async () => {
const { service, tx } = setup()
await service.completeBooking('booking', 'owner')
await expect(service.completeBooking('booking', 'owner')).rejects.toBeInstanceOf(BadRequestException)
expect(tx.membership.update).toHaveBeenCalledTimes(1)
})
it('does not activate an unconfirmed booking', async () => {
const { service, booking, tx } = setup()
booking.status = BookingStatus.PENDING_CONFIRMATION
await expect(service.completeBooking('booking', 'owner')).rejects.toBeInstanceOf(BadRequestException)
expect(tx.membership.update).not.toHaveBeenCalled()
})
it('does not mark the booking completed if card activation fails', async () => {
const { service, tx } = setup()
tx.membership.update.mockRejectedValueOnce(new Error('write failed'))
await expect(service.completeBooking('booking', 'owner')).rejects.toThrow('write failed')
expect(tx.booking.update).not.toHaveBeenCalled()
expect(tx.bookingStatusHistory.create).not.toHaveBeenCalled()
})
})
describe('Pending monthly-card benefits and reservations', () => {
it('never expires or auto-activates while waiting, even with all sessions reserved', () => {
for (const remainingTimes of [9, 0, null]) {
expect(getMembershipEffectiveState({ ...pending(), remainingTimes }, new Date('2030-01-01'))).toEqual({ status: MembershipStatus.SCHEDULED, remainingTimes })
}
expect(getMembershipRenewalHint([pending()], checkInTime)).toBeNull()
})
it('allows a pending card to book, but rejects courses beyond its purchased duration or without quota', () => {
const slot = { date: '2026-11-01', startTime: '09:00', endTime: '10:00' }
expect(canUseMembershipForSlot(pending(), slot, checkInTime)).toBe(true)
expect(canUseMembershipForSlot({ ...pending(), remainingTimes: 0 }, slot, checkInTime)).toBe(false)
expect(canUseMembershipForSlot(pending(), { ...slot, date: '2026-11-30' }, checkInTime)).toBe(false)
expect(canUseMembershipForSlot({ ...pending(), remainingTimes: null }, slot, checkInTime)).toBe(true)
})
})

View File

@@ -9,6 +9,9 @@ import {
import { Booking, Membership, TimeSlot, BookingStatusHistory } from '@prisma/client' import { Booking, Membership, TimeSlot, BookingStatusHistory } from '@prisma/client'
import { import {
BookingStatus, BookingStatus,
isMembershipAwaitingCheckIn,
getStudioDayStart,
addDurationDays,
CardTypeCategory, CardTypeCategory,
CONTRACT_UNSIGNED_MESSAGE, CONTRACT_UNSIGNED_MESSAGE,
MembershipContractStatus, MembershipContractStatus,
@@ -129,7 +132,7 @@ export class BookingService {
} }
// A card cannot be used after its validity period ends. // A card cannot be used after its validity period ends.
if (membership.expireDate <= new Date()) { if (membership.expireDate && membership.expireDate <= new Date()) {
throw new BadRequestException('Membership has expired') throw new BadRequestException('Membership has expired')
} }
if (membership.cardType.type === CardTypeCategory.DURATION && !canUseMembershipForSlot(membership, timeSlot)) throw new BadRequestException('课程不在该月卡周期内,或该周期已无可用课时') if (membership.cardType.type === CardTypeCategory.DURATION && !canUseMembershipForSlot(membership, timeSlot)) throw new BadRequestException('课程不在该月卡周期内,或该周期已无可用课时')
@@ -246,7 +249,7 @@ export class BookingService {
} }
// 2. Validate membership still has available times // 2. Validate membership still has available times
if (existing.membership.expireDate <= new Date()) { if (existing.membership.expireDate && existing.membership.expireDate <= new Date()) {
throw new BadRequestException('Membership has expired') throw new BadRequestException('Membership has expired')
} }
if (existing.membership.status === MembershipStatus.EXPIRED) throw new BadRequestException('Membership has expired') if (existing.membership.status === MembershipStatus.EXPIRED) throw new BadRequestException('Membership has expired')
@@ -373,7 +376,7 @@ export class BookingService {
`Membership is not active (status: ${membership.status})`, `Membership is not active (status: ${membership.status})`,
) )
} }
if (membership.expireDate <= new Date()) { if (membership.expireDate && membership.expireDate <= new Date()) {
throw new BadRequestException('Membership has expired') throw new BadRequestException('Membership has expired')
} }
@@ -488,9 +491,11 @@ export class BookingService {
remark: string, remark: string,
): Promise<BookingWithRelations> { ): Promise<BookingWithRelations> {
const booking = await this.prisma.$transaction(async (tx) => { const booking = await this.prisma.$transaction(async (tx) => {
// Use the same member lock as booking confirmation and purchased-card issuance.
await tx.$queryRaw`SELECT u.id FROM users u INNER JOIN bookings b ON b.user_id = u.id WHERE b.id = ${bookingId} FOR UPDATE`
const existing = await tx.booking.findUnique({ const existing = await tx.booking.findUnique({
where: { id: bookingId }, where: { id: bookingId },
include: { timeSlot: true }, include: { timeSlot: true, membership: { include: { cardType: true } } },
}) })
if (!existing) { if (!existing) {
@@ -507,8 +512,22 @@ export class BookingService {
operatorId, operatorId,
} }
if (toStatus === BookingStatus.COMPLETED) { if (toStatus === BookingStatus.COMPLETED) {
updateData.completedAt = new Date() const now = new Date()
updateData.reviewReminderDueAt = new Date(Date.now() + 24 * 3600000) updateData.completedAt = now
updateData.reviewReminderDueAt = new Date(now.getTime() + 24 * 3600000)
if (existing.membership && isMembershipAwaitingCheckIn(existing.membership)) {
const startDate = getStudioDayStart(now)
const durationDays = existing.membership.activationDurationDays ?? existing.membership.cardType.durationDays
if (durationDays <= 0) throw new BadRequestException('月卡有效天数无效,请联系馆主')
await tx.membership.update({
where: { id: existing.membershipId },
data: {
startDate,
expireDate: addDurationDays(startDate, durationDays),
status: existing.membership.remainingTimes === 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE,
},
})
}
} }
const updated = await tx.booking.update({ const updated = await tx.booking.update({

View File

@@ -77,6 +77,18 @@ describe('contract formatting', () => {
}) })
describe('buildContractDocument', () => { describe('buildContractDocument', () => {
it('explains first-check-in activation in both the monthly dates and numbered clauses', () => {
const document = render({}, { kind: 'MONTHLY', durationDays: 30, startDate: null, expireDate: null })
const text = allText(document)
expect(text).toContain('权益生效时间:首次课程核销当天生效(中国时区)')
expect(text).toContain('不从下单、付款或签署合同之日起计时')
expect(text).toContain('取消预约或标记未出席均不触发生效')
expect(text).toContain('该日为第 1 天,有效期共 30 个自然日')
expect(text).not.toContain('自上一期届满时起生效')
expect(text).not.toContain('微信支付确认后立即生效')
expect(document.sections[1].clauses[5].text).toMatch(/^6\./)
})
it('renders a multi-times contract with standard-price refunds and fixed clause numbers', () => { it('renders a multi-times contract with standard-price refunds and fixed clause numbers', () => {
const document = render() const document = render()
const refund = document.sections.find(section => section.title.includes('退费'))! const refund = document.sections.find(section => section.title.includes('退费'))!

View File

@@ -104,6 +104,19 @@ describe('ContractService.issueForPaidOrder', () => {
expect(prisma.membershipContract.create).not.toHaveBeenCalled() expect(prisma.membershipContract.create).not.toHaveBeenCalled()
}) })
it('issues a deferred monthly contract without inventing dates from the payment time', async () => {
const { service, prisma } = buildService()
prisma.membershipContract.findUnique.mockResolvedValue(null)
await service.issueForPaidOrder(prisma as never, {
order: order as never,
cardType: { ...timesCard, type: 'DURATION', durationDays: 7 } as never,
membership: { id: 'monthly', createdAt: paidAt, startDate: null, expireDate: null, remainingTimes: 10, activationDurationDays: 30 } as never,
paidAt,
})
const terms = prisma.membershipContract.create.mock.calls[0][0].data.terms
expect(terms).toMatchObject({ kind: 'MONTHLY', startDate: null, expireDate: null, durationDays: 30, scheduled: false })
})
it('issues a booking-blocking contract with frozen purchase terms', async () => { it('issues a booking-blocking contract with frozen purchase terms', async () => {
const { service, prisma } = buildService() const { service, prisma } = buildService()
prisma.membershipContract.findUnique.mockResolvedValue(null) prisma.membershipContract.findUnique.mockResolvedValue(null)

View File

@@ -9,7 +9,7 @@ import {
type ContractSection, type ContractSection,
} from '@mp-pilates/shared' } from '@mp-pilates/shared'
export const CONTRACT_TEMPLATE_VERSION = '2026-10-v2' export const CONTRACT_TEMPLATE_VERSION = '2026-10-v3'
const CHINA_OFFSET_MS = 8 * 3600 * 1000 const CHINA_OFFSET_MS = 8 * 3600 * 1000
const PENDING_TEXT = '(待填写)' const PENDING_TEXT = '(待填写)'
@@ -228,7 +228,7 @@ function buildCardRows(input: ContractRenderInput): ContractRow[] {
} }
rows.push( rows.push(
{ label: '权益生效时间', value: terms.startDate ? formatChinaDateTime(terms.startDate) : '微信支付确认后立即生效' }, { label: '权益生效时间', value: terms.startDate ? formatChinaDateTime(terms.startDate) : isMonthly ? '首次课程核销当天生效(中国时区)' : '微信支付确认后立即生效' },
{ {
label: '权益截止时间', label: '权益截止时间',
value: terms.expireDate ? formatChinaDateTime(terms.expireDate) : `自生效时间起 ${terms.durationDays} 天`, value: terms.expireDate ? formatChinaDateTime(terms.expireDate) : `自生效时间起 ${terms.durationDays} 天`,
@@ -280,7 +280,9 @@ function buildSections(input: ContractRenderInput): ContractSection[] {
? `本卡为月卡,每期有效期 ${terms.durationDays} 天,每期课时为${unlimited ? '有效期内不限次数' : ` ${terms.lessonCount} 节`}。每期课时仅限当期使用,未使用的课时不结转至下一期;依本条第 6、7 款办理到期升级的除外。` ? `本卡为月卡,每期有效期 ${terms.durationDays} 天,每期课时为${unlimited ? '有效期内不限次数' : ` ${terms.lessonCount} 节`}。每期课时仅限当期使用,未使用的课时不结转至下一期;依本条第 6、7 款办理到期升级的除外。`
: `本卡为多次卡,共含课时 ${terms.lessonCount ?? 0} 节,自付款确认时起生效,有效期 ${terms.durationDays} 天,具体起止时间以「会员卡信息」所载为准。`, : `本卡为多次卡,共含课时 ${terms.lessonCount ?? 0} 节,自付款确认时起生效,有效期 ${terms.durationDays} 天,具体起止时间以「会员卡信息」所载为准。`,
isMonthly isMonthly
? '上一期月卡尚未到期时购买的,新一期自上一期届满时起生效;当期课时用完后,甲方可在小程序中确认提前启用下一期,下一期自启用时起计算完整期限。各期生效与截止时间以小程序记录为准。' ? terms.startDate === null
? `本期月卡自甲方使用本卡首次预约成功且课程被核销完成的当天起生效,不从下单、付款或签署合同之日起计时;仅预约、确认预约、取消预约或标记未出席均不触发生效。生效日按中国时区的自然日计算,该日为第 1 天,有效期共 ${terms.durationDays} 个自然日,至第 ${terms.durationDays} 天结束时届满。核销完成后,具体起止时间可在小程序「我的会员卡」查询;每张新购月卡独立计算,无需手动提前启用。`
: '上一期月卡尚未到期时购买的,新一期自上一期届满时起生效;当期课时用完后,甲方可在小程序中确认提前启用下一期,下一期自启用时起计算完整期限。各期生效与截止时间以小程序记录为准。'
: '在有效期内续购同一卡种的,新增课时并入原会员卡,有效期按续购时小程序公示的规则相应延长,并以续购时签署的合同为准;已过期会员卡的剩余课时不并入新购卡。', : '在有效期内续购同一卡种的,新增课时并入原会员卡,有效期按续购时小程序公示的规则相应延长,并以续购时签署的合同为准;已过期会员卡的剩余课时不并入新购卡。',
bold(`有效期届满后,甲方不能直接使用本卡预约课程;甲方已按第七条办理延期或暂停的,按调整后的有效期执行。有效期届满不当然免除乙方依法应承担的退费、结算等义务,甲方可依第六条及法律规定主张相关权利${isMonthly ? ',也可依本条第 6、7 款办理到期升级' : ''}。`), bold(`有效期届满后,甲方不能直接使用本卡预约课程;甲方已按第七条办理延期或暂停的,按调整后的有效期执行。有效期届满不当然免除乙方依法应承担的退费、结算等义务,甲方可依第六条及法律规定主张相关权利${isMonthly ? ',也可依本条第 6、7 款办理到期升级' : ''}。`),
'乙方在小程序中实时展示剩余课时、有效期及预约、上课记录,甲方可随时查询。', '乙方在小程序中实时展示剩余课时、有效期及预约、上课记录,甲方可随时查询。',

View File

@@ -118,16 +118,16 @@ export class ContractService {
cardTypeId: cardType.id, cardTypeId: cardType.id,
cardName: cardType.name, cardName: cardType.name,
lessonCount: cardType.totalTimes, lessonCount: cardType.totalTimes,
durationDays: cardType.durationDays, durationDays: membership.activationDurationDays ?? cardType.durationDays,
listPrice, listPrice,
amount, amount,
discountNote: amount < listPrice ? (order.inviteInviterId ? '已享好友邀请 95 折' : '已享优惠') : null, discountNote: amount < listPrice ? (order.inviteInviterId ? '已享好友邀请 95 折' : '已享优惠') : null,
orderNos: [order.orderNo], orderNos: [order.orderNo],
paidAt: paidAt.toISOString(), paidAt: paidAt.toISOString(),
acceptedAt: order.contractAcceptedAt?.toISOString() ?? null, acceptedAt: order.contractAcceptedAt?.toISOString() ?? null,
startDate: (reusedRecord ? paidAt : membership.startDate).toISOString(), startDate: (reusedRecord ? paidAt : membership.startDate)?.toISOString() ?? null,
expireDate: membership.expireDate.toISOString(), expireDate: membership.expireDate?.toISOString() ?? null,
scheduled: membership.startDate > paidAt, scheduled: membership.startDate !== null && membership.startDate > paidAt,
renewal, renewal,
availableTimesAfter: renewal ? membership.remainingTimes : null, availableTimesAfter: renewal ? membership.remainingTimes : null,
legacyRemainingTimes: null, legacyRemainingTimes: null,
@@ -189,7 +189,7 @@ export class ContractService {
where: { where: {
...(userId ? { userId } : {}), ...(userId ? { userId } : {}),
status: { in: [MembershipStatus.ACTIVE, MembershipStatus.SCHEDULED] }, status: { in: [MembershipStatus.ACTIVE, MembershipStatus.SCHEDULED] },
expireDate: { gt: now }, OR: [{ expireDate: { gt: now } }, { startDate: null, expireDate: null, cardType: { type: CardTypeCategory.DURATION } }],
inviteRewardGrants: { none: {} }, inviteRewardGrants: { none: {} },
}, },
include: { include: {
@@ -266,16 +266,16 @@ export class ContractService {
cardTypeId: membership.cardTypeId, cardTypeId: membership.cardTypeId,
cardName: membership.cardType.name, cardName: membership.cardType.name,
lessonCount: membership.totalTimes ?? membership.cardType.totalTimes, lessonCount: membership.totalTimes ?? membership.cardType.totalTimes,
durationDays: membership.cardType.durationDays, durationDays: membership.activationDurationDays ?? membership.cardType.durationDays,
listPrice: Number(membership.cardType.price), listPrice: Number(membership.cardType.price),
amount, amount,
discountNote: null, discountNote: null,
orderNos: orders.map(order => order.orderNo), orderNos: orders.map(order => order.orderNo),
paidAt: lastPaid?.toISOString() ?? null, paidAt: lastPaid?.toISOString() ?? null,
acceptedAt: null, acceptedAt: null,
startDate: membership.startDate.toISOString(), startDate: membership.startDate?.toISOString() ?? null,
expireDate: membership.expireDate.toISOString(), expireDate: membership.expireDate?.toISOString() ?? null,
scheduled: membership.startDate > now, scheduled: membership.startDate !== null && membership.startDate > now,
renewal: false, renewal: false,
availableTimesAfter: null, availableTimesAfter: null,
legacyRemainingTimes: state.remainingTimes, legacyRemainingTimes: state.remainingTimes,
@@ -301,8 +301,8 @@ export class ContractService {
kind: membership.cardType.type === CardTypeCategory.DURATION ? 'MONTHLY' : 'MULTI_TIMES', kind: membership.cardType.type === CardTypeCategory.DURATION ? 'MONTHLY' : 'MULTI_TIMES',
remainingTimes, remainingTimes,
totalTimes: membership.totalTimes, totalTimes: membership.totalTimes,
startDate: membership.startDate.toISOString(), startDate: membership.startDate?.toISOString() ?? null,
expireDate: membership.expireDate.toISOString(), expireDate: membership.expireDate?.toISOString() ?? null,
} }
} }
@@ -653,8 +653,8 @@ export class ContractService {
cardName: terms.cardName, cardName: terms.cardName,
kind: terms.kind, kind: terms.kind,
amount: terms.amount, amount: terms.amount,
startDate: terms.startDate ?? contract.createdAt.toISOString(), startDate: terms.startDate,
expireDate: terms.expireDate ?? contract.createdAt.toISOString(), expireDate: terms.expireDate,
createdAt: contract.createdAt.toISOString(), createdAt: contract.createdAt.toISOString(),
signedAt: contract.signedAt?.toISOString() ?? null, signedAt: contract.signedAt?.toISOString() ?? null,
voidReason: contract.voidReason, voidReason: contract.voidReason,

View File

@@ -53,7 +53,7 @@ describe('computeMembershipGrant', () => {
expect(result.isRenewal).toBe(false) expect(result.isRenewal).toBe(false)
expect(result.remainingTimes).toBe(10) expect(result.remainingTimes).toBe(10)
expect(result.totalTimes).toBe(10) expect(result.totalTimes).toBe(10)
expect(result.expireDate.getTime()).toBe(now.getTime() + 90 * 86_400_000) 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', () => { it('always treats TRIAL as a new grant even if an old trial exists', () => {
@@ -89,7 +89,7 @@ describe('computeMembershipGrant', () => {
expect(result.isRenewal).toBe(true) expect(result.isRenewal).toBe(true)
expect(result.remainingTimes).toBe(13) expect(result.remainingTimes).toBe(13)
expect(result.totalTimes).toBe(20) expect(result.totalTimes).toBe(20)
expect(result.expireDate.getTime()).toBe(expireDate.getTime() + 90 * 86_400_000) expect(result.expireDate!.getTime()).toBe(expireDate.getTime() + 90 * 86_400_000)
}) })
it('does not inherit leftover times when the TIMES card is expired', () => { it('does not inherit leftover times when the TIMES card is expired', () => {
@@ -107,10 +107,10 @@ describe('computeMembershipGrant', () => {
expect(result.isRenewal).toBe(true) expect(result.isRenewal).toBe(true)
expect(result.remainingTimes).toBe(10) expect(result.remainingTimes).toBe(10)
expect(result.totalTimes).toBe(10) expect(result.totalTimes).toBe(10)
expect(result.expireDate.getTime()).toBe(now.getTime() + 90 * 86_400_000) expect(result.expireDate!.getTime()).toBe(now.getTime() + 90 * 86_400_000)
}) })
it('extends a still-active DURATION card from its current expireDate', () => { it('defers activation of a new unlimited monthly card while keeping the old period', () => {
const expireDate = new Date('2026-07-01T00:00:00Z') const expireDate = new Date('2026-07-01T00:00:00Z')
const result = computeMembershipGrant({ const result = computeMembershipGrant({
existing: { existing: {
@@ -125,10 +125,11 @@ describe('computeMembershipGrant', () => {
expect(result.isRenewal).toBe(true) expect(result.isRenewal).toBe(true)
expect(result.remainingTimes).toBeNull() expect(result.remainingTimes).toBeNull()
expect(result.expireDate.getTime()).toBe(expireDate.getTime() + 30 * 86_400_000) expect(result.expireDate).toBeNull()
expect(result.startDate).toBeNull()
}) })
it('starts an expired DURATION card from now', () => { it('defers activation of a new monthly card after the old one expires', () => {
const result = computeMembershipGrant({ const result = computeMembershipGrant({
existing: { existing: {
remainingTimes: null, remainingTimes: null,
@@ -141,7 +142,8 @@ describe('computeMembershipGrant', () => {
}) })
expect(result.isRenewal).toBe(true) expect(result.isRenewal).toBe(true)
expect(result.expireDate.getTime()).toBe(now.getTime() + 30 * 86_400_000) expect(result.expireDate).toBeNull()
expect(result.startDate).toBeNull()
}) })
it.each([ it.each([
@@ -152,15 +154,16 @@ describe('computeMembershipGrant', () => {
{ expireDate: '2026-05-01T00:00:00.000Z', status: MembershipStatus.USED_UP }, { expireDate: '2026-05-01T00:00:00.000Z', status: MembershipStatus.USED_UP },
])('resets an expired 10-session monthly plan with 6 unused sessions: %j', expiry => { ])('resets an expired 10-session monthly plan with 6 unused sessions: %j', expiry => {
const result = computeMembershipGrant({ existing: { remainingTimes: 6, totalTimes: 10, ...expiry }, cardType: limitedDurationCard, now }) const result = computeMembershipGrant({ existing: { remainingTimes: 6, totalTimes: 10, ...expiry }, cardType: limitedDurationCard, now })
expect(result).toEqual({ startDate: now, isRenewal: true, remainingTimes: 10, totalTimes: 10, expireDate: new Date(now.getTime() + 30 * 86_400_000) }) expect(result).toEqual({ startDate: null, isRenewal: true, remainingTimes: 10, totalTimes: 10, expireDate: null })
}) })
it('does not extend an explicitly expired unlimited monthly plan from a future stored date', () => { it('does not extend an explicitly expired unlimited monthly plan from a future stored date', () => {
const result = computeMembershipGrant({ existing: { remainingTimes: null, totalTimes: null, expireDate: '2026-07-01T00:00:00.000Z', status: MembershipStatus.EXPIRED }, cardType: durationCard, now }) const result = computeMembershipGrant({ existing: { remainingTimes: null, totalTimes: null, expireDate: '2026-07-01T00:00:00.000Z', status: MembershipStatus.EXPIRED }, cardType: durationCard, now })
expect(result.expireDate.getTime()).toBe(now.getTime() + 30 * 86_400_000) expect(result.expireDate).toBeNull()
expect(result.startDate).toBeNull()
}) })
it('keeps a renewed monthly quota separate and schedules it after the current period', () => { it('keeps a renewed monthly quota separate until its first check-in', () => {
const expireDate = new Date('2026-07-01T00:00:00Z') const expireDate = new Date('2026-07-01T00:00:00Z')
const result = computeMembershipGrant({ const result = computeMembershipGrant({
existing: { existing: {
@@ -177,9 +180,10 @@ describe('computeMembershipGrant', () => {
isRenewal: true, isRenewal: true,
remainingTimes: 10, remainingTimes: 10,
totalTimes: 10, totalTimes: 10,
startDate: expireDate, startDate: null,
}) })
expect(result.expireDate.getTime()).toBe(expireDate.getTime() + 30 * 86_400_000) expect(result.expireDate).toBeNull()
expect(result.startDate).toBeNull()
}) })
}) })

View File

@@ -164,7 +164,7 @@ describe('MembershipService', () => {
where: expect.objectContaining({ where: expect.objectContaining({
userId: 'user-001', userId: 'user-001',
status: { in: [MembershipStatus.ACTIVE, MembershipStatus.SCHEDULED] }, status: { in: [MembershipStatus.ACTIVE, MembershipStatus.SCHEDULED] },
startDate: { lte: expect.any(Date) }, AND: expect.any(Array),
}), }),
orderBy: { expireDate: 'asc' }, orderBy: { expireDate: 'asc' },
}), }),
@@ -187,7 +187,7 @@ describe('MembershipService', () => {
expect(mockPrismaService.membership.findFirst).toHaveBeenCalledWith( expect(mockPrismaService.membership.findFirst).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
where: expect.objectContaining({ where: expect.objectContaining({
OR: [{ remainingTimes: { gt: 0 } }, { remainingTimes: null }], AND: expect.arrayContaining([{ OR: [{ remainingTimes: { gt: 0 } }, { remainingTimes: null }] }]),
}), }),
}), }),
) )
@@ -441,7 +441,7 @@ describe('MembershipService', () => {
mockPrismaService.membership.findFirst.mockResolvedValue(existing) mockPrismaService.membership.findFirst.mockResolvedValue(existing)
mockPrismaService.membership.create.mockImplementation(async ({data}) => ({id:'next-period',...data})) mockPrismaService.membership.create.mockImplementation(async ({data}) => ({id:'next-period',...data}))
const result = await service.grantPurchasedCard(tx, 'user-001', mockDurationCardType, now) const result = await service.grantPurchasedCard(tx, 'user-001', mockDurationCardType, now)
expect(mockPrismaService.membership.create).toHaveBeenCalledWith({data: {userId:'user-001',cardTypeId:mockDurationCardType.id,startDate:existing.expireDate,expireDate:new Date('2026-07-31T00:00:00Z'),remainingTimes:null,totalTimes:null,status:MembershipStatus.SCHEDULED}}) expect(mockPrismaService.membership.create).toHaveBeenCalledWith({data: {userId:'user-001',cardTypeId:mockDurationCardType.id,startDate:null,expireDate:null,activationDurationDays:30,remainingTimes:null,totalTimes:null,status:MembershipStatus.SCHEDULED}})
expect(result.id).toBe('next-period') expect(result.id).toBe('next-period')
expect(mockPrismaService.membership.update).not.toHaveBeenCalled() expect(mockPrismaService.membership.update).not.toHaveBeenCalled()
}) })
@@ -485,7 +485,7 @@ describe('MembershipService', () => {
mockPrismaService.membership.findFirst.mockResolvedValue(existing) mockPrismaService.membership.findFirst.mockResolvedValue(existing)
mockPrismaService.membership.create.mockResolvedValue({ ...existing, id: 'new-period', remainingTimes: 10 }) mockPrismaService.membership.create.mockResolvedValue({ ...existing, id: 'new-period', remainingTimes: 10 })
await service.grantPurchasedCard(tx, 'user-001', { ...mockDurationCardType, totalTimes: 10 }, now) await service.grantPurchasedCard(tx, 'user-001', { ...mockDurationCardType, totalTimes: 10 }, now)
expect(mockPrismaService.membership.create).toHaveBeenCalledWith({ data: { userId: 'user-001', cardTypeId: mockDurationCardType.id, startDate: now, remainingTimes: 10, totalTimes: 10, expireDate: new Date('2026-07-01T00:00:00Z'), status: MembershipStatus.ACTIVE } }) expect(mockPrismaService.membership.create).toHaveBeenCalledWith({ data: { userId: 'user-001', cardTypeId: mockDurationCardType.id, startDate: null, remainingTimes: 10, totalTimes: 10, expireDate: null, activationDurationDays: 30, status: MembershipStatus.SCHEDULED } })
expect(mockPrismaService.membership.update).not.toHaveBeenCalled() expect(mockPrismaService.membership.update).not.toHaveBeenCalled()
}) })

View File

@@ -18,7 +18,7 @@ describe('Independent monthly periods', () => {
const db = { $transaction: jest.fn(async (callback: (value: typeof tx) => unknown) => callback(tx)) } const db = { $transaction: jest.fn(async (callback: (value: typeof tx) => unknown) => callback(tx)) }
return { tx, service: new MembershipService(db as unknown as PrismaService) } return { tx, service: new MembershipService(db as unknown as PrismaService) }
} }
it('queues repeated purchases in distinct records and never changes the current 6-session balance', async () => { it('defers repeated purchases in distinct records and never changes the current 6-session balance', async () => {
const { tx, service } = setup() const { tx, service } = setup()
const old = { ...current(), remainingTimes: 6, status: 'ACTIVE' } const old = { ...current(), remainingTimes: 6, status: 'ACTIVE' }
const periods = [old] const periods = [old]
@@ -27,8 +27,14 @@ describe('Independent monthly periods', () => {
await service.grantPurchasedCard(tx as never, 'member', cardType as never, now) await service.grantPurchasedCard(tx as never, 'member', cardType as never, now)
await service.grantPurchasedCard(tx as never, 'member', cardType as never, now) await service.grantPurchasedCard(tx as never, 'member', cardType as never, now)
expect(periods.map(p => p.remainingTimes)).toEqual([6, 10, 10]) expect(periods.map(p => p.remainingTimes)).toEqual([6, 10, 10])
expect(periods[1]).toMatchObject({ startDate: old.expireDate, expireDate: new Date('2026-07-31T00:00:00Z'), status: 'SCHEDULED' }) expect(periods[1]).toMatchObject({ startDate: null, expireDate: null, activationDurationDays: 30, status: 'SCHEDULED' })
expect(periods[2]).toMatchObject({ startDate: periods[1].expireDate, expireDate: new Date('2026-08-30T00:00:00Z'), status: 'SCHEDULED' }) expect(periods[2]).toMatchObject({ startDate: null, expireDate: null, activationDurationDays: 30, status: 'SCHEDULED' })
expect(tx.membership.update).not.toHaveBeenCalled()
})
it('does not allow manual activation of a new monthly card awaiting its first check-in', async () => {
const { tx, service } = setup()
tx.membership.findFirst.mockResolvedValue({ ...next(), startDate: null, expireDate: null, activationDurationDays: 30 })
await expect(service.activateNextPeriod('member', 'next', now)).rejects.toThrow('首次课程核销')
expect(tx.membership.update).not.toHaveBeenCalled() expect(tx.membership.update).not.toHaveBeenCalled()
}) })
it('activates the earliest next period and shifts later periods while preserving their full durations', async () => { it('activates the earliest next period and shifts later periods while preserving their full durations', async () => {

View File

@@ -3,5 +3,14 @@ import { MembershipStatus } from '@mp-pilates/shared'
/** A scheduled period becomes usable at its timestamp without waiting for cron. */ /** A scheduled period becomes usable at its timestamp without waiting for cron. */
export function currentMembershipWhere(now = new Date()): Prisma.MembershipWhereInput { export function currentMembershipWhere(now = new Date()): Prisma.MembershipWhereInput {
return { status: { in: [MembershipStatus.ACTIVE, MembershipStatus.SCHEDULED] }, startDate: { lte: now }, expireDate: { gt: now }, OR: [{ remainingTimes: { gt: 0 } }, { remainingTimes: null }] } return {
status: { in: [MembershipStatus.ACTIVE, MembershipStatus.SCHEDULED] },
AND: [
{ OR: [
{ startDate: { lte: now }, expireDate: { gt: now } },
{ startDate: null, expireDate: null, cardType: { type: 'DURATION' } },
] },
{ OR: [{ remainingTimes: { gt: 0 } }, { remainingTimes: null }] },
],
}
} }

View File

@@ -1,6 +1,6 @@
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common' import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common'
import { CardType, Membership, Prisma } from '@prisma/client' import { CardType, Membership, Prisma } from '@prisma/client'
import { CardTypeCategory, MembershipStatus, computeMembershipGrant, getMembershipEffectiveState, getStudioSlotRange } from '@mp-pilates/shared' import { CardTypeCategory, MembershipStatus, computeMembershipGrant, getMembershipEffectiveState, getStudioSlotRange, isMembershipAwaitingCheckIn } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service' import { PrismaService } from '../prisma/prisma.service'
import { CreateCardTypeDto } from './dto/create-card-type.dto' import { CreateCardTypeDto } from './dto/create-card-type.dto'
import { UpdateCardTypeDto } from './dto/update-card-type.dto' import { UpdateCardTypeDto } from './dto/update-card-type.dto'
@@ -146,7 +146,8 @@ export class MembershipService {
expireDate: grant.expireDate, expireDate: grant.expireDate,
remainingTimes: grant.remainingTimes, remainingTimes: grant.remainingTimes,
totalTimes: grant.totalTimes, totalTimes: grant.totalTimes,
status: grant.startDate > now ? MembershipStatus.SCHEDULED : MembershipStatus.ACTIVE, ...(cardType.type === CardTypeCategory.DURATION ? { activationDurationDays: cardType.durationDays } : {}),
status: grant.startDate === null || grant.startDate > now ? MembershipStatus.SCHEDULED : MembershipStatus.ACTIVE,
}, },
}) })
} }
@@ -167,11 +168,12 @@ export class MembershipService {
await tx.$queryRaw`SELECT id FROM users WHERE id = ${userId} FOR UPDATE` await tx.$queryRaw`SELECT id FROM users WHERE id = ${userId} FOR UPDATE`
const target = await tx.membership.findFirst({ where: { id: membershipId, userId }, include: { cardType: true } }) const target = await tx.membership.findFirst({ where: { id: membershipId, userId }, include: { cardType: true } })
if (!target) throw new NotFoundException('月卡周期不存在') if (!target) throw new NotFoundException('月卡周期不存在')
if (target.cardType.type !== CardTypeCategory.DURATION || target.startDate <= now || target.status === MembershipStatus.EXPIRED) throw new BadRequestException('只能提前启用待生效的月卡周期') if (isMembershipAwaitingCheckIn(target)) throw new BadRequestException('该月卡将在首次课程核销当天自动生效,无需提前启用')
if (target.cardType.type !== CardTypeCategory.DURATION || !target.startDate || target.startDate <= now || target.status === MembershipStatus.EXPIRED) throw new BadRequestException('只能提前启用待生效的月卡周期')
if (target.remainingTimes === 0) throw new BadRequestException('该周期课时已全部预约,请先检查预约记录') if (target.remainingTimes === 0) throw new BadRequestException('该周期课时已全部预约,请先检查预约记录')
const periods = await tx.membership.findMany({ where: { userId, cardTypeId: target.cardTypeId, status: { not: MembershipStatus.EXPIRED }, expireDate: { gt: now } }, orderBy: [{ startDate: 'asc' }, { createdAt: 'asc' }] }) const periods = await tx.membership.findMany({ where: { userId, cardTypeId: target.cardTypeId, status: { not: MembershipStatus.EXPIRED }, expireDate: { gt: now } }, orderBy: [{ startDate: 'asc' }, { createdAt: 'asc' }] })
const current = periods.filter(period => period.startDate <= now) const current = periods.filter(period => period.startDate && period.startDate <= now)
const queued = periods.filter(period => period.startDate > now) const queued = periods.filter(period => period.startDate && period.startDate > now)
if (queued[0]?.id !== membershipId) throw new BadRequestException('请按顺序启用最早的待生效周期') if (queued[0]?.id !== membershipId) throw new BadRequestException('请按顺序启用最早的待生效周期')
if (current.some(period => period.remainingTimes !== 0)) throw new BadRequestException('本期课时尚未用完,请继续使用本期权益') if (current.some(period => period.remainingTimes !== 0)) throw new BadRequestException('本期课时尚未用完,请继续使用本期权益')
@@ -181,7 +183,7 @@ export class MembershipService {
let cursor = now let cursor = now
const shifted = queued.map(period => { const shifted = queued.map(period => {
const duration = period.expireDate.getTime() - period.startDate.getTime() const duration = period.expireDate!.getTime() - period.startDate!.getTime()
if (duration <= 0) throw new BadRequestException('月卡周期日期无效,请联系馆主') if (duration <= 0) throw new BadRequestException('月卡周期日期无效,请联系馆主')
const dates = { startDate: cursor, expireDate: new Date(cursor.getTime() + duration) } const dates = { startDate: cursor, expireDate: new Date(cursor.getTime() + duration) }
cursor = dates.expireDate cursor = dates.expireDate

View File

@@ -387,8 +387,7 @@ describe('PaymentService', () => {
prisma.membership.create.mockImplementation(async ({data}) => ({ ...existing, ...data, id: 'new-monthly-period' })) prisma.membership.create.mockImplementation(async ({data}) => ({ ...existing, ...data, id: 'new-monthly-period' }))
await service.handleWxNotify(headers, successBody) await service.handleWxNotify(headers, successBody)
const data = prisma.membership.create.mock.calls[0][0].data const data = prisma.membership.create.mock.calls[0][0].data
expect(data).toMatchObject({ remainingTimes: 10, totalTimes: 10, status: MembershipStatus.ACTIVE, startDate: expect.any(Date) }) expect(data).toMatchObject({ remainingTimes: 10, totalTimes: 10, status: MembershipStatus.SCHEDULED, startDate: null, expireDate: null, activationDurationDays: 30 })
expect(data.expireDate.getTime() - data.startDate.getTime()).toBe(30 * 86_400_000)
expect(prisma.membership.update).not.toHaveBeenCalled() expect(prisma.membership.update).not.toHaveBeenCalled()
expect(prisma.order.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ membershipId: 'new-monthly-period' }) })) expect(prisma.order.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ membershipId: 'new-monthly-period' }) }))
}) })

View File

@@ -54,6 +54,7 @@ const makeBooking = (
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const mockPrisma = { const mockPrisma = {
cardType: { findUnique: jest.fn().mockImplementation(async ({ where }) => ({ id: where.id, type: where.id.includes('duration') ? CardTypeCategory.DURATION : CardTypeCategory.TIMES, durationDays: 30 })) },
lessonSupplement: { lessonSupplement: {
aggregate: jest.fn().mockResolvedValue({ _sum: { quantity: 0 } }), aggregate: jest.fn().mockResolvedValue({ _sum: { quantity: 0 } }),
groupBy: jest.fn().mockResolvedValue([]), groupBy: jest.fn().mockResolvedValue([]),
@@ -93,7 +94,7 @@ const mockConfigService = {
// Tests // Tests
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const usableWhere = { status: { in: [MembershipStatus.ACTIVE, MembershipStatus.SCHEDULED] }, startDate: { lte: expect.any(Date) }, expireDate: { gt: expect.any(Date) }, OR: [{ remainingTimes: { gt: 0 } }, { remainingTimes: null }] } const usableWhere = { status: { in: [MembershipStatus.ACTIVE, MembershipStatus.SCHEDULED] }, AND: [{ OR: [{ startDate: { lte: expect.any(Date) }, expireDate: { gt: expect.any(Date) } }, { startDate: null, expireDate: null, cardType: { type: CardTypeCategory.DURATION } }] }, { OR: [{ remainingTimes: { gt: 0 } }, { remainingTimes: null }] }] }
describe('UserService', () => { describe('UserService', () => {
let service: UserService let service: UserService
@@ -634,7 +635,10 @@ describe('UserService', () => {
cardTypeId: 'card-duration-001', cardTypeId: 'card-duration-001',
remainingTimes: 10, remainingTimes: 10,
totalTimes: 10, totalTimes: 10,
status: MembershipStatus.ACTIVE, status: MembershipStatus.SCHEDULED,
startDate: null,
expireDate: null,
activationDurationDays: 30,
}), }),
}), }),
) )

View File

@@ -67,8 +67,8 @@ export class LessonSupplementService {
where: { id: card.id, userId, remainingTimes: card.remainingTimes, updatedAt: card.updatedAt }, where: { id: card.id, userId, remainingTimes: card.remainingTimes, updatedAt: card.updatedAt },
data: { data: {
remainingTimes: { decrement: dto.quantity }, remainingTimes: { decrement: dto.quantity },
status: card.expireDate <= new Date() || card.status === MembershipStatus.EXPIRED status: (card.expireDate !== null && card.expireDate <= new Date()) || card.status === MembershipStatus.EXPIRED
? MembershipStatus.EXPIRED : remaining === 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE, ? MembershipStatus.EXPIRED : card.startDate === null ? MembershipStatus.SCHEDULED : remaining === 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE,
}, },
}) })
if (changed.count !== 1) throw new ConflictException('会员卡已发生变化,请刷新后重试') if (changed.count !== 1) throw new ConflictException('会员卡已发生变化,请刷新后重试')
@@ -104,8 +104,8 @@ export class LessonSupplementService {
where: { id: card.id, userId, remainingTimes: card.remainingTimes, updatedAt: card.updatedAt }, where: { id: card.id, userId, remainingTimes: card.remainingTimes, updatedAt: card.updatedAt },
data: { data: {
remainingTimes: { increment: row.deductedTimes }, remainingTimes: { increment: row.deductedTimes },
status: card.expireDate <= new Date() || card.status === MembershipStatus.EXPIRED status: (card.expireDate !== null && card.expireDate <= new Date()) || card.status === MembershipStatus.EXPIRED
? MembershipStatus.EXPIRED : MembershipStatus.ACTIVE, ? MembershipStatus.EXPIRED : card.startDate === null ? MembershipStatus.SCHEDULED : MembershipStatus.ACTIVE,
}, },
}) })
if (restored.count !== 1) throw new ConflictException('会员卡已发生变化,请刷新后重试') if (restored.count !== 1) throw new ConflictException('会员卡已发生变化,请刷新后重试')

View File

@@ -38,8 +38,9 @@ function serializeMembership(membership: {
cardTypeId: string cardTypeId: string
remainingTimes: number | null remainingTimes: number | null
totalTimes: number | null totalTimes: number | null
startDate: Date startDate: Date | null
expireDate: Date expireDate: Date | null
activationDurationDays?: number | null
status: string status: string
createdAt: Date createdAt: Date
updatedAt: Date updatedAt: Date
@@ -67,8 +68,9 @@ function serializeMembership(membership: {
cardTypeId: rest.cardTypeId, cardTypeId: rest.cardTypeId,
remainingTimes: effective.remainingTimes, remainingTimes: effective.remainingTimes,
totalTimes: rest.totalTimes, totalTimes: rest.totalTimes,
startDate: rest.startDate.toISOString(), startDate: rest.startDate?.toISOString() ?? null,
expireDate: rest.expireDate.toISOString(), expireDate: rest.expireDate?.toISOString() ?? null,
activationDurationDays: rest.activationDurationDays ?? null,
status: effective.status, status: effective.status,
createdAt: rest.createdAt.toISOString(), createdAt: rest.createdAt.toISOString(),
updatedAt: rest.updatedAt.toISOString(), updatedAt: rest.updatedAt.toISOString(),
@@ -689,6 +691,21 @@ export class UserService {
throw new NotFoundException('Membership not found') throw new NotFoundException('Membership not found')
} }
const cardType = await this.prisma.cardType.findUnique({ where: { id: dto.cardTypeId } })
if (!cardType) throw new NotFoundException('Card type not found')
const awaitingCheckIn = cardType.type === CardTypeCategory.DURATION
&& (!existing || (existing.startDate === null && existing.expireDate === null))
const activationData = awaitingCheckIn
? {
startDate: null,
expireDate: null,
status: MembershipStatus.SCHEDULED,
activationDurationDays: existing?.cardTypeId === cardType.id
? existing.activationDurationDays ?? cardType.durationDays
: cardType.durationDays,
}
: {}
if (existing) { if (existing) {
const totalTimes = remainingTimes === null const totalTimes = remainingTimes === null
? null ? null
@@ -697,14 +714,14 @@ export class UserService {
: remainingTimes : remainingTimes
return this.prisma.membership.update({ return this.prisma.membership.update({
where: { id: existing.id }, where: { id: existing.id, ...(awaitingCheckIn ? { startDate: null, expireDate: null } : {}) },
data: { ...data, totalTimes }, data: { ...data, ...activationData, totalTimes },
include: { cardType: true }, include: { cardType: true },
}) })
} }
return this.prisma.membership.create({ return this.prisma.membership.create({
data: { userId, totalTimes: remainingTimes, ...data }, data: { userId, totalTimes: remainingTimes, ...data, ...activationData },
include: { cardType: true }, include: { cardType: true },
}) })
} }

View File

@@ -58,6 +58,8 @@ export {
addDurationDays, addDurationDays,
getMembershipDaysLeft, getMembershipDaysLeft,
isMembershipExpired, isMembershipExpired,
isMembershipAwaitingCheckIn,
getStudioDayStart,
getMembershipEffectiveState, getMembershipEffectiveState,
getStudioSlotRange, getStudioSlotRange,
canUseMembershipForSlot, canUseMembershipForSlot,

View File

@@ -7,7 +7,7 @@ export const MEMBERSHIP_DAY_MS = 86_400_000
export interface MembershipGrantExisting { export interface MembershipGrantExisting {
readonly remainingTimes: number | null readonly remainingTimes: number | null
readonly totalTimes: number | null readonly totalTimes: number | null
readonly expireDate: Date | string readonly expireDate: Date | string | null
readonly status: string readonly status: string
} }
@@ -18,9 +18,9 @@ export interface MembershipGrantCardType {
} }
export interface MembershipGrantResult { export interface MembershipGrantResult {
readonly startDate: Date readonly startDate: Date | null
readonly remainingTimes: number | null readonly remainingTimes: number | null
readonly expireDate: Date readonly expireDate: Date | null
readonly totalTimes: number | null readonly totalTimes: number | null
readonly isRenewal: boolean readonly isRenewal: boolean
} }
@@ -35,10 +35,10 @@ export interface MembershipRenewalHint {
} }
export interface RenewalHintMembership { export interface RenewalHintMembership {
readonly startDate?: Date | string readonly startDate?: Date | string | null
readonly cardTypeId: string readonly cardTypeId: string
readonly remainingTimes: number | null readonly remainingTimes: number | null
readonly expireDate: Date | string readonly expireDate: Date | string | null
readonly status: MembershipStatus readonly status: MembershipStatus
readonly cardType: { readonly cardType: {
readonly type: CardTypeCategory readonly type: CardTypeCategory
@@ -49,7 +49,8 @@ export function addDurationDays(from: Date, durationDays: number): Date {
return new Date(from.getTime() + durationDays * MEMBERSHIP_DAY_MS) return new Date(from.getTime() + durationDays * MEMBERSHIP_DAY_MS)
} }
export function getMembershipDaysLeft(expireDate: Date | string, now: Date = new Date()): number { export function getMembershipDaysLeft(expireDate: Date | string | null, now: Date = new Date()): number {
if (expireDate === null) return 0
return Math.ceil((new Date(expireDate).getTime() - now.getTime()) / MEMBERSHIP_DAY_MS) return Math.ceil((new Date(expireDate).getTime() - now.getTime()) / MEMBERSHIP_DAY_MS)
} }
@@ -57,18 +58,33 @@ export function canRenewCardType(type: CardTypeCategory): boolean {
return type !== CardTypeCategory.TRIAL return type !== CardTypeCategory.TRIAL
} }
export function isMembershipExpired(membership: { readonly status: string; readonly expireDate: Date | string }, now: Date = new Date()): boolean { export function isMembershipExpired(membership: { readonly status: string; readonly expireDate: Date | string | null }, now: Date = new Date()): boolean {
return membership.status === MembershipStatus.EXPIRED || new Date(membership.expireDate).getTime() <= now.getTime() return membership.status === MembershipStatus.EXPIRED || (membership.expireDate !== null && new Date(membership.expireDate).getTime() <= now.getTime())
}
export function isMembershipAwaitingCheckIn(membership: {
readonly startDate?: Date | string | null; readonly expireDate: Date | string | null
readonly status: string; readonly cardType: { readonly type: string }
}): boolean {
return membership.cardType.type === CardTypeCategory.DURATION && membership.startDate === null
&& membership.expireDate === null && membership.status !== MembershipStatus.EXPIRED
}
/** The activation day counts as day one, in the studio's China time zone. */
export function getStudioDayStart(now: Date): Date {
const day = new Date(now.getTime() + 8 * 3600_000).toISOString().slice(0, 10)
return new Date(`${day}T00:00:00+08:00`)
} }
/** Effective benefits at read time; retain the stored balance for historical auditing. */ /** Effective benefits at read time; retain the stored balance for historical auditing. */
export function getMembershipEffectiveState(membership: { export function getMembershipEffectiveState(membership: {
readonly startDate?: Date | string readonly startDate?: Date | string | null
readonly status: string readonly status: string
readonly expireDate: Date | string readonly expireDate: Date | string | null
readonly remainingTimes: number | null readonly remainingTimes: number | null
readonly cardType: { readonly type: string } readonly cardType: { readonly type: string }
}, now: Date = new Date()): { status: MembershipStatus; remainingTimes: number | null } { }, now: Date = new Date()): { status: MembershipStatus; remainingTimes: number | null } {
if (isMembershipAwaitingCheckIn(membership)) return { status: MembershipStatus.SCHEDULED, remainingTimes: membership.remainingTimes }
const expired = isMembershipExpired(membership, now) const expired = isMembershipExpired(membership, now)
const future = membership.startDate != null && new Date(membership.startDate).getTime() > now.getTime() const future = membership.startDate != null && new Date(membership.startDate).getTime() > now.getTime()
return { return {
@@ -88,6 +104,10 @@ export function computeMembershipGrant(params: {
}): MembershipGrantResult { }): MembershipGrantResult {
const { existing, cardType, now } = params const { existing, cardType, now } = params
if (cardType.type === CardTypeCategory.DURATION) {
return { startDate: null, expireDate: null, remainingTimes: cardType.totalTimes, totalTimes: cardType.totalTimes, isRenewal: existing !== null }
}
if (!existing || cardType.type === CardTypeCategory.TRIAL) { if (!existing || cardType.type === CardTypeCategory.TRIAL) {
return { return {
startDate: now, startDate: now,
@@ -98,15 +118,11 @@ export function computeMembershipGrant(params: {
} }
} }
const existingExpire = new Date(existing.expireDate) const existingExpire = existing.expireDate === null ? now : new Date(existing.expireDate)
const isExpired = isMembershipExpired(existing, now) const isExpired = isMembershipExpired(existing, now)
const baseDate = isExpired ? now : existingExpire const baseDate = isExpired ? now : existingExpire
const expireDate = addDurationDays(baseDate, cardType.durationDays) const expireDate = addDurationDays(baseDate, cardType.durationDays)
if (cardType.type === CardTypeCategory.DURATION) {
return { startDate: baseDate, remainingTimes: cardType.totalTimes, totalTimes: cardType.totalTimes, expireDate, isRenewal: true }
}
if (cardType.totalTimes == null) { if (cardType.totalTimes == null) {
return { return {
startDate: now, startDate: now,
@@ -143,7 +159,7 @@ export function pickRenewalTarget<T extends RenewalHintMembership>(
const matches = memberships const matches = memberships
.filter((m) => m.cardTypeId === cardTypeId && canRenewCardType(m.cardType.type)) .filter((m) => m.cardTypeId === cardTypeId && canRenewCardType(m.cardType.type))
.slice() .slice()
.sort((a, b) => new Date(b.expireDate).getTime() - new Date(a.expireDate).getTime()) .sort((a, b) => (b.expireDate ? new Date(b.expireDate).getTime() : Infinity) - (a.expireDate ? new Date(a.expireDate).getTime() : Infinity))
if (matches[0]?.cardType.type === CardTypeCategory.DURATION) { if (matches[0]?.cardType.type === CardTypeCategory.DURATION) {
return matches.find(m => m.status !== MembershipStatus.EXPIRED) ?? matches[0] return matches.find(m => m.status !== MembershipStatus.EXPIRED) ?? matches[0]
@@ -227,7 +243,7 @@ export function getMembershipRenewalHint(
const renewable = memberships const renewable = memberships
.filter((m) => canRenewCardType(m.cardType.type)) .filter((m) => canRenewCardType(m.cardType.type))
.slice() .slice()
.sort((a, b) => new Date(b.expireDate).getTime() - new Date(a.expireDate).getTime()) .sort((a, b) => (b.expireDate ? new Date(b.expireDate).getTime() : Infinity) - (a.expireDate ? new Date(a.expireDate).getTime() : Infinity))
const recommended = renewable[0] const recommended = renewable[0]
return { return {
@@ -245,14 +261,22 @@ export function getStudioSlotRange(date: Date | string, startTime: string, endTi
} }
export function canUseMembershipForSlot(membership: { export function canUseMembershipForSlot(membership: {
readonly startDate?: Date | string; readonly expireDate: Date | string; readonly status: string readonly startDate?: Date | string | null; readonly expireDate: Date | string | null; readonly status: string
readonly remainingTimes: number | null; readonly cardType: { readonly type: string } readonly activationDurationDays?: number | null
readonly remainingTimes: number | null; readonly cardType: { readonly type: string; readonly durationDays?: number }
}, slot: { readonly date: Date | string; readonly startTime: string; readonly endTime: string }, now: Date = new Date()): boolean { }, slot: { readonly date: Date | string; readonly startTime: string; readonly endTime: string }, now: Date = new Date()): boolean {
const state = getMembershipEffectiveState(membership, now) const state = getMembershipEffectiveState(membership, now)
if (state.status !== MembershipStatus.ACTIVE && state.status !== MembershipStatus.SCHEDULED) return false if (state.status !== MembershipStatus.ACTIVE && state.status !== MembershipStatus.SCHEDULED) return false
if (membership.remainingTimes !== null && membership.remainingTimes <= 0) return false if (membership.remainingTimes !== null && membership.remainingTimes <= 0) return false
if (membership.cardType.type !== CardTypeCategory.DURATION) return state.status === MembershipStatus.ACTIVE if (membership.cardType.type !== CardTypeCategory.DURATION) return state.status === MembershipStatus.ACTIVE
const { start, end } = getStudioSlotRange(slot.date, slot.startTime, slot.endTime) const { start, end } = getStudioSlotRange(slot.date, slot.startTime, slot.endTime)
return Number.isFinite(start.getTime()) && end > start if (isMembershipAwaitingCheckIn(membership)) {
&& (membership.startDate == null || start >= new Date(membership.startDate)) && end <= new Date(membership.expireDate) const duration = membership.activationDurationDays ?? membership.cardType.durationDays ?? 0
const earliestActivation = getStudioDayStart(now)
// Any reservation must still fit if the first check-in happens today.
return Number.isFinite(start.getTime()) && end > start && start >= earliestActivation
&& end <= addDurationDays(earliestActivation, duration)
}
return Number.isFinite(start.getTime()) && end > start
&& membership.expireDate !== null && (membership.startDate == null || start >= new Date(membership.startDate)) && end <= new Date(membership.expireDate)
} }

View File

@@ -53,8 +53,8 @@ export interface MembershipContractSummary {
readonly cardName: string readonly cardName: string
readonly kind: ContractKind readonly kind: ContractKind
readonly amount: number readonly amount: number
readonly startDate: string readonly startDate: string | null
readonly expireDate: string readonly expireDate: string | null
readonly createdAt: string readonly createdAt: string
readonly signedAt: string | null readonly signedAt: string | null
readonly voidReason: string | null readonly voidReason: string | null
@@ -132,8 +132,8 @@ export interface LegacyContractCandidate {
readonly kind: ContractKind readonly kind: ContractKind
readonly remainingTimes: number | null readonly remainingTimes: number | null
readonly totalTimes: number | null readonly totalTimes: number | null
readonly startDate: string readonly startDate: string | null
readonly expireDate: string readonly expireDate: string | null
} }
export interface LegacyContractSyncResult { export interface LegacyContractSyncResult {

View File

@@ -7,8 +7,9 @@ export interface Membership {
readonly cardTypeId: string readonly cardTypeId: string
readonly remainingTimes: number | null readonly remainingTimes: number | null
readonly totalTimes: number | null readonly totalTimes: number | null
readonly startDate: string readonly startDate: string | null
readonly expireDate: string readonly expireDate: string | null
readonly activationDurationDays?: number | null
readonly status: MembershipStatus readonly status: MembershipStatus
readonly createdAt: string readonly createdAt: string
readonly updatedAt: string readonly updatedAt: string