diff --git a/packages/app/src/components/CardShop.vue b/packages/app/src/components/CardShop.vue
index 94d3a6d..b300a0c 100644
--- a/packages/app/src/components/CardShop.vue
+++ b/packages/app/src/components/CardShop.vue
@@ -47,7 +47,10 @@
- {{ card.name }}
+
+ {{ card.name }}
+ 续
+
有效期 {{ card.durationDays }} 天
@@ -80,15 +83,32 @@
@@ -254,8 +258,8 @@ const lowestRemainingTimes = computed(() => {
}
.action-renew {
- background: #e0e0e0;
- .pill-action-text { color: #555; }
+ background: #e8efe9;
+ .pill-action-text { color: #5f7a6e; }
}
/* ── Renew strip (running low) ── */
@@ -266,21 +270,21 @@ const lowestRemainingTimes = computed(() => {
gap: 8rpx;
margin-top: 12rpx;
padding: 14rpx 24rpx;
- background: linear-gradient(135deg, #FF6B35, #FF8E53);
+ background: linear-gradient(135deg, #8fa89a, #a8c0b4);
border-radius: 24rpx;
}
.renew-strip-text {
font-size: 22rpx;
font-weight: 500;
- color: #fff;
+ color: #fffcfa;
letter-spacing: 0.5rpx;
}
.renew-strip-arrow {
font-size: 28rpx;
font-weight: 700;
- color: rgba(255, 255, 255, 0.8);
+ color: rgba(255, 252, 250, 0.82);
line-height: 1;
}
diff --git a/packages/app/src/pages/admin/members.vue b/packages/app/src/pages/admin/members.vue
index 3532ea9..8bc0fd7 100644
--- a/packages/app/src/pages/admin/members.vue
+++ b/packages/app/src/pages/admin/members.vue
@@ -178,12 +178,12 @@
-
+
- 已使用 {{ getMembershipUsedTimes(detailMembership) }} / {{ detailMembership.cardType.totalTimes }} 次
+ 已使用 {{ getMembershipUsedTimes(detailMembership) }} / {{ getMembershipTotalTimes(detailMembership) }} 次
@@ -313,7 +313,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue'
import { onReachBottom } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import { getSystemLayout } from '../../utils/system'
-import { getCardTypeLabel, getCardGradientClass, getMembershipProgressWidth, getMembershipUsedTimes } from '../../utils/format'
+import { getCardTypeLabel, getCardGradientClass, getMembershipProgressWidth, getMembershipUsedTimes, getMembershipTotalTimes } from '../../utils/format'
import { useAdminStore } from '../../stores/admin'
import type { MemberSummary, UserMembership } from '../../stores/admin'
import type { CardType } from '@mp-pilates/shared'
diff --git a/packages/app/src/pages/card/detail.vue b/packages/app/src/pages/card/detail.vue
index b804ac2..4df5ed6 100644
--- a/packages/app/src/pages/card/detail.vue
+++ b/packages/app/src/pages/card/detail.vue
@@ -1,6 +1,6 @@
-
-
+
+
@@ -54,7 +54,10 @@
- {{ c.name }}
+
+ {{ c.name }}
+ 续
+
有效期 {{ c.durationDays }} 天
@@ -85,7 +88,7 @@
-
+
-
- {{ typeLabel }}
+
+
+ {{ typeLabel }}
+
+
+ 续卡
+
+ 把练习节奏接下去
{{ cardData.name }}
¥
@@ -144,15 +153,49 @@
{{ cardData.description }}
+
+
+ 续卡对照
+ {{ renewalPreview.statusLabel }}
+
+
+
+
+ 当前
+ {{ renewalPreview.currentHighlight }}
+ {{ renewalPreview.highlightUnit }}
+ {{ renewalPreview.currentExpire }}
+
+
+
+
+
+
+
+
+
+ 续后
+ {{ renewalPreview.afterHighlight }}
+ {{ renewalPreview.highlightUnit }}
+ {{ renewalPreview.afterExpire }}
+
+
+
+
+
+
•
- 购买后立即生效,有效期 {{ cardData.durationDays }} 天
+ {{ isRenewal ? `续卡后有效期接续,再延长 ${cardData.durationDays} 天` : `购买后立即生效,有效期 ${cardData.durationDays} 天` }}
•
@@ -186,17 +229,17 @@
-
+
- 实付金额
+ {{ isRenewal ? '续卡金额' : '实付金额' }}
¥{{ formatPrice(cardData.price) }}
- {{ buying ? '支付中...' : '立即购买' }}
+ {{ buying ? '支付中...' : (isRenewal ? '确认续卡' : '立即购买') }}
@@ -206,10 +249,10 @@
@@ -401,6 +521,10 @@ onMounted(() => {
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
}
+.page--renew {
+ background: #f4f1eb;
+}
+
/* ── Loading ─────────────────────────────────────────── */
.loading-wrap {
padding: 0 0 32rpx;
@@ -473,19 +597,23 @@ onMounted(() => {
overflow: hidden;
&.hero--times {
- background: linear-gradient(135deg, #E8D5C4 0%, #D4BFA8 100%);
+ background: linear-gradient(160deg, #f3e9de 0%, #e6d5c4 100%);
}
&.hero--duration {
- background: linear-gradient(135deg, #D8C8DC 0%, #C4AECB 100%);
+ background: linear-gradient(160deg, #eee6ea 0%, #dccfd6 100%);
}
&.hero--trial {
- background: linear-gradient(135deg, #C8D8D2 0%, #A9C4BC 100%);
+ background: linear-gradient(160deg, #e4eee9 0%, #c9dbd3 100%);
}
&.hero--custom {
- background: #333;
+ background: #5c534c;
+ }
+
+ &.hero--renew {
+ background: linear-gradient(165deg, #e4ede8 0%, #f3ebe3 52%, #e8dcd0 100%);
}
}
@@ -501,7 +629,7 @@ onMounted(() => {
.hero-deco {
position: absolute;
border-radius: 50%;
- background: rgba(255, 255, 255, 0.35);
+ background: rgba(255, 255, 255, 0.38);
pointer-events: none;
&--1 {
@@ -519,26 +647,57 @@ onMounted(() => {
}
}
+.hero--renew .hero-deco--1 {
+ background: rgba(143, 168, 154, 0.22);
+}
+
+.hero--renew .hero-deco--2 {
+ background: rgba(255, 252, 248, 0.45);
+}
+
+.hero-badge-row {
+ display: flex;
+ align-items: center;
+ gap: 12rpx;
+ z-index: 1;
+}
+
.hero-badge {
align-self: flex-start;
padding: 8rpx 22rpx;
border-radius: 20rpx;
- background: rgba(74, 64, 53, 0.1);
- border: 1rpx solid rgba(74, 64, 53, 0.15);
+ background: rgba(255, 252, 248, 0.45);
+ border: 1rpx solid rgba(95, 122, 110, 0.18);
z-index: 1;
}
+.hero-badge--renew {
+ background: rgba(143, 168, 154, 0.22);
+ border-color: rgba(95, 122, 110, 0.22);
+
+ .hero-badge-text {
+ color: #5f7a6e;
+ }
+}
+
.hero-badge-text {
font-size: 22rpx;
- color: $brand-color;
+ color: #5c534c;
font-weight: 600;
- letter-spacing: 1rpx;
+ letter-spacing: 2rpx;
+}
+
+.hero-kicker {
+ font-size: 24rpx;
+ color: #7a8f85;
+ letter-spacing: 4rpx;
+ z-index: 1;
}
.hero-name {
font-size: 48rpx;
font-weight: 800;
- color: $brand-color;
+ color: #4f5d56;
letter-spacing: 1rpx;
z-index: 1;
}
@@ -553,20 +712,20 @@ onMounted(() => {
.hero-currency {
font-size: 28rpx;
font-weight: 600;
- color: rgba(74, 64, 53, 0.7);
+ color: #7a8f85;
line-height: 1;
}
.hero-price {
font-size: 64rpx;
font-weight: 800;
- color: $brand-color;
+ color: #4f5d56;
line-height: 1;
}
.hero-original {
font-size: 28rpx;
- color: rgba(74, 64, 53, 0.4);
+ color: rgba(92, 83, 76, 0.4);
text-decoration: line-through;
margin-left: 8rpx;
}
@@ -592,7 +751,7 @@ onMounted(() => {
width: 6rpx;
height: 28rpx;
border-radius: 3rpx;
- background: $primary-dark;
+ background: #8fa89a;
flex-shrink: 0;
}
@@ -633,13 +792,13 @@ onMounted(() => {
.cell-value {
font-size: 44rpx;
font-weight: 800;
- color: #1a1a1a;
+ color: #4f5d56;
line-height: 1.1;
}
.cell-label {
font-size: 22rpx;
- color: #999;
+ color: #9a9088;
}
/* ── Description card ────────────────────────────────── */
@@ -656,6 +815,128 @@ onMounted(() => {
line-height: 1.75;
}
+.renew-compare {
+ background: linear-gradient(180deg, #fffcfa 0%, #f3eee6 100%);
+ border-radius: 28rpx;
+ padding: 32rpx 28rpx 28rpx;
+ box-shadow: 0 10rpx 32rpx rgba(95, 122, 110, 0.08);
+ border: 1rpx solid rgba(143, 168, 154, 0.28);
+}
+
+.renew-compare-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ margin-bottom: 28rpx;
+}
+
+.renew-compare-kicker {
+ font-size: 22rpx;
+ font-weight: 600;
+ letter-spacing: 4rpx;
+ color: #7a8f85;
+}
+
+.renew-compare-status {
+ font-size: 20rpx;
+ color: #f7faf8;
+ background: #8fa89a;
+ padding: 6rpx 16rpx;
+ border-radius: 20rpx;
+ letter-spacing: 1rpx;
+}
+
+.renew-compare-body {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.renew-col {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ min-width: 0;
+}
+
+.renew-col-label {
+ font-size: 20rpx;
+ color: #a3988e;
+ letter-spacing: 2rpx;
+ margin-bottom: 8rpx;
+}
+
+.renew-col-value {
+ font-size: 72rpx;
+ font-weight: 800;
+ color: #8a7e74;
+ line-height: 1;
+ font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
+}
+
+.renew-col--after .renew-col-value {
+ color: #5f7a6e;
+}
+
+.renew-col-unit {
+ font-size: 22rpx;
+ color: #8a7e74;
+ margin-top: 8rpx;
+}
+
+.renew-col-date {
+ font-size: 22rpx;
+ color: #a3988e;
+ margin-top: 12rpx;
+ letter-spacing: 1rpx;
+}
+
+.renew-arrow {
+ width: auto;
+ padding: 0 8rpx 28rpx;
+ display: flex;
+ flex-direction: row;
+ align-items: center;
+ gap: 8rpx;
+ flex-shrink: 0;
+}
+
+.renew-arrow-line {
+ width: 28rpx;
+ height: 2rpx;
+ background: rgba(143, 168, 154, 0.45);
+}
+
+.renew-arrow-diamond {
+ width: 14rpx;
+ height: 14rpx;
+ background: #8fa89a;
+ transform: rotate(45deg);
+}
+
+.renew-compare-foot {
+ margin-top: 28rpx;
+ padding-top: 24rpx;
+ border-top: 1rpx dashed rgba(143, 168, 154, 0.4);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 10rpx;
+}
+
+.renew-compare-add {
+ font-size: 24rpx;
+ font-weight: 600;
+ color: #5f7a6e;
+ letter-spacing: 1rpx;
+}
+
+.renew-compare-note {
+ font-size: 20rpx;
+ color: #a3988e;
+}
+
/* ── Features card ───────────────────────────────────── */
.features-card {
background: #fff;
@@ -676,14 +957,14 @@ onMounted(() => {
.feature-dot {
font-size: 26rpx;
- color: $primary-dark;
+ color: #8fa89a;
line-height: 1.65;
flex-shrink: 0;
}
.feature-text {
font-size: 26rpx;
- color: #555;
+ color: #6f675f;
line-height: 1.65;
}
@@ -693,14 +974,19 @@ onMounted(() => {
bottom: 0;
left: 0;
right: 0;
- background: #fff;
- border-top: 1rpx solid #f0ece8;
+ background: rgba(255, 252, 250, 0.96);
+ border-top: 1rpx solid #efe8df;
padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom));
display: flex;
flex-direction: row;
align-items: center;
gap: 24rpx;
- box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.06);
+ box-shadow: 0 -8rpx 28rpx rgba(95, 122, 110, 0.06);
+}
+
+.bottom-bar--renew {
+ background: #fffcfa;
+ border-top-color: rgba(143, 168, 154, 0.22);
}
.price-summary {
@@ -711,24 +997,24 @@ onMounted(() => {
.summary-label {
font-size: 22rpx;
- color: #999;
+ color: #9a9088;
}
.summary-price {
font-size: 40rpx;
font-weight: 800;
- color: $primary-dark;
+ color: #5f7a6e;
}
.buy-btn {
flex: 1;
height: 88rpx;
border-radius: 44rpx;
- background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
+ background: #6e8b7e;
display: flex;
align-items: center;
justify-content: center;
- box-shadow: 0 4rpx 16rpx rgba(26, 26, 46, 0.3);
+ box-shadow: 0 8rpx 24rpx rgba(95, 122, 110, 0.22);
&:active {
opacity: 0.85;
@@ -737,13 +1023,17 @@ onMounted(() => {
&--loading {
opacity: 0.6;
}
+
+ &--renew {
+ background: linear-gradient(135deg, #6e8b7e 0%, #8fa89a 100%);
+ }
}
.buy-btn-text {
- font-size: 32rpx;
+ font-size: 30rpx;
font-weight: 700;
- color: $primary-dark;
- letter-spacing: 2rpx;
+ color: #fffcfa;
+ letter-spacing: 4rpx;
}
/* ── All cards list ────────────────────────────────────── */
@@ -849,6 +1139,25 @@ onMounted(() => {
line-height: 1.2;
}
+.card-name-row {
+ display: flex;
+ align-items: center;
+ gap: 10rpx;
+ min-width: 0;
+}
+
+.renew-tag {
+ flex-shrink: 0;
+ font-size: 18rpx;
+ font-weight: 700;
+ color: #5f7a6e;
+ background: rgba(143, 168, 154, 0.2);
+ padding: 2rpx 10rpx;
+ border-radius: 8rpx;
+ line-height: 1.4;
+ letter-spacing: 1rpx;
+}
+
.card-validity {
font-size: 23rpx;
color: $text-secondary;
diff --git a/packages/app/src/pages/profile/membership.vue b/packages/app/src/pages/profile/membership.vue
index 89cc744..f98d736 100644
--- a/packages/app/src/pages/profile/membership.vue
+++ b/packages/app/src/pages/profile/membership.vue
@@ -63,7 +63,7 @@
{{ m.remainingTimes }}
次剩余
-
+
- 已用 {{ getMembershipUsedTimes(m) }},共 {{ m.cardType.totalTimes }} 次
+ 已用 {{ getMembershipUsedTimes(m) }},共 {{ getMembershipTotalTimes(m) }} 次
@@ -94,6 +94,10 @@
{{ m.expireDate.slice(0, 10) }}
+
+
+ 续卡
+
@@ -133,6 +137,10 @@
{{ m.expireDate.slice(0, 10) }}
+
+
+ 续同款
+
@@ -153,7 +161,7 @@ import type { MembershipWithCardType } from '@mp-pilates/shared'
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
import { useUserStore } from '../../stores/user'
import { getSystemLayout } from '../../utils/system'
-import { getCardTypeLabel, getMembershipProgressWidth, getMembershipUsedTimes } from '../../utils/format'
+import { getCardTypeLabel, getMembershipProgressWidth, getMembershipUsedTimes, getMembershipTotalTimes } from '../../utils/format'
import CustomNavBar from '../../components/CustomNavBar.vue'
const userStore = useUserStore()
@@ -215,9 +223,18 @@ async function onRefresh() {
}
function goStore() {
+ uni.$emit('scrollToCardShop')
uni.switchTab({ url: '/pages/home/index' })
}
+function canRenewMembership(m: MembershipWithCardType): boolean {
+ return m.cardType.type !== CardTypeCategory.TRIAL
+}
+
+function goRenew(m: MembershipWithCardType) {
+ uni.navigateTo({ url: `/pages/card/detail?id=${m.cardTypeId}` })
+}
+
onMounted(() => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
loadMemberships()
@@ -571,6 +588,32 @@ onMounted(() => {
z-index: 1;
}
+.mc-renew {
+ z-index: 1;
+ align-self: stretch;
+ height: 64rpx;
+ border-radius: 32rpx;
+ background: rgba(255, 252, 248, 0.42);
+ border: 1rpx solid rgba(143, 168, 154, 0.4);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ &:active { opacity: 0.85; }
+}
+
+.mc-renew--inactive {
+ background: rgba(143, 168, 154, 0.1);
+ border-color: rgba(143, 168, 154, 0.28);
+}
+
+.mc-renew-text {
+ font-size: 24rpx;
+ font-weight: 600;
+ color: #5f7a6e;
+ letter-spacing: 4rpx;
+}
+
/* ── FAB ──────────────────────────────────── */
.fab {
position: fixed;
diff --git a/packages/app/src/stores/user.ts b/packages/app/src/stores/user.ts
index 7c6e43d..e46cd81 100644
--- a/packages/app/src/stores/user.ts
+++ b/packages/app/src/stores/user.ts
@@ -5,8 +5,13 @@ import type {
UserStatsResponse,
MembershipWithCardType,
} from '@mp-pilates/shared'
-import { UserRole, MembershipStatus } from '@mp-pilates/shared'
+import {
+ UserRole,
+ MembershipStatus,
+ getMembershipRenewalHint,
+} from '@mp-pilates/shared'
import { wxLogin, isLoggedIn, logout as authLogout } from '../utils/auth'
+import { setUnauthorizedHandler } from '../utils/session'
import { get, put } from '../utils/request'
import { ROUTES } from '../utils/routes'
import { cacheSubscriptionMessageTemplateConfig, resetSubscriptionMessageTemplateCache } from '../utils/wechat-subscription'
@@ -32,6 +37,7 @@ export const useUserStore = defineStore('user', () => {
memberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
)
const hasValidMembership = computed(() => activeMemberships.value.length > 0)
+ const renewalHint = computed(() => getMembershipRenewalHint(memberships.value))
const inviteShareEligible = computed(() => !!user.value?.inviteShareEligible)
// Actions
@@ -110,15 +116,21 @@ export const useUserStore = defineStore('user', () => {
}
}
- function logout() {
- authLogout()
- resetSubscriptionMessageTemplateCache()
+ function clearSession() {
token.value = ''
user.value = null
stats.value = null
memberships.value = []
+ resetSubscriptionMessageTemplateCache()
}
+ function logout() {
+ authLogout()
+ clearSession()
+ }
+
+ setUnauthorizedHandler(clearSession)
+
return {
user,
stats,
@@ -129,6 +141,7 @@ export const useUserStore = defineStore('user', () => {
isAdmin,
activeMemberships,
hasValidMembership,
+ renewalHint,
inviteShareEligible,
login,
loginWithSetup,
diff --git a/packages/app/src/utils/format.ts b/packages/app/src/utils/format.ts
index c86ed3d..51236ae 100644
--- a/packages/app/src/utils/format.ts
+++ b/packages/app/src/utils/format.ts
@@ -4,6 +4,7 @@ import { FlashSalePhase } from '@mp-pilates/shared'
/** Minimal membership shape needed by progress/usage helpers. */
interface MembershipLike {
readonly remainingTimes: number | null
+ readonly totalTimes?: number | null
readonly cardType: { readonly totalTimes: number | null }
}
@@ -84,17 +85,24 @@ export function getCardGradientClass(type: CardTypeCategory | string): string {
return 'gradient--times'
}
+/** 会员卡累计购入次数(续卡后优先用卡上的快照) */
+export function getMembershipTotalTimes(membership: MembershipLike): number | null {
+ return membership.totalTimes ?? membership.cardType.totalTimes ?? null
+}
+
/** 会员卡进度百分比(剩余 / 总次数,clamp 到 0~100%) */
export function getMembershipProgressWidth(membership: MembershipLike): string {
- if (membership.remainingTimes === null || !membership.cardType.totalTimes) return '0%'
- const pct = (membership.remainingTimes / membership.cardType.totalTimes) * 100
+ const totalTimes = getMembershipTotalTimes(membership)
+ if (membership.remainingTimes === null || !totalTimes) return '0%'
+ const pct = (membership.remainingTimes / totalTimes) * 100
return `${Math.max(0, Math.min(100, pct))}%`
}
/** 已使用次数(不低于 0,防止管理员调高剩余次数导致负值) */
export function getMembershipUsedTimes(membership: MembershipLike): number {
- if (membership.remainingTimes === null || !membership.cardType.totalTimes) return 0
- return Math.max(0, membership.cardType.totalTimes - membership.remainingTimes)
+ const totalTimes = getMembershipTotalTimes(membership)
+ if (membership.remainingTimes === null || !totalTimes) return 0
+ return Math.max(0, totalTimes - membership.remainingTimes)
}
/** 格式化倒计时:HH:MM:SS */
diff --git a/packages/app/src/utils/request.ts b/packages/app/src/utils/request.ts
index 893a352..6fee6f1 100644
--- a/packages/app/src/utils/request.ts
+++ b/packages/app/src/utils/request.ts
@@ -1,4 +1,5 @@
import type { ApiResponse, PaginatedData } from '@mp-pilates/shared'
+import { notifyUnauthorized } from './session'
// 统一使用线上服务地址
const BASE_URL = 'https://focus.richarjiang.com/api'
@@ -25,8 +26,9 @@ export function request(options: RequestOptions): Promise {
},
success: (res) => {
if (res.statusCode === 401) {
- uni.removeStorageSync('token')
- uni.showToast({ title: '请重新登录', icon: 'none' })
+ if (!options.url.startsWith('/auth/login')) {
+ notifyUnauthorized()
+ }
reject(new Error('Unauthorized'))
return
}
diff --git a/packages/app/src/utils/session.ts b/packages/app/src/utils/session.ts
new file mode 100644
index 0000000..42907d0
--- /dev/null
+++ b/packages/app/src/utils/session.ts
@@ -0,0 +1,22 @@
+const UNAUTHORIZED_TOAST_GAP_MS = 2000
+
+let unauthorizedHandler: (() => void) | null = null
+let lastUnauthorizedAt = 0
+
+export function setUnauthorizedHandler(handler: () => void): void {
+ unauthorizedHandler = handler
+}
+
+/** Clear persisted token and in-memory session after the server rejects auth. */
+export function notifyUnauthorized(): void {
+ const now = Date.now()
+ const shouldToast = now - lastUnauthorizedAt > UNAUTHORIZED_TOAST_GAP_MS
+ lastUnauthorizedAt = now
+
+ uni.removeStorageSync('token')
+ unauthorizedHandler?.()
+
+ if (shouldToast) {
+ uni.showToast({ title: '登录已过期,请重新登录', icon: 'none' })
+ }
+}
diff --git a/packages/server/prisma/migrations/20260907040000_add_membership_renewal_fields/migration.sql b/packages/server/prisma/migrations/20260907040000_add_membership_renewal_fields/migration.sql
new file mode 100644
index 0000000..4e46fcb
--- /dev/null
+++ b/packages/server/prisma/migrations/20260907040000_add_membership_renewal_fields/migration.sql
@@ -0,0 +1,20 @@
+-- AlterTable
+ALTER TABLE `memberships` ADD COLUMN `total_times` INTEGER NULL;
+
+-- AlterTable
+ALTER TABLE `orders` ADD COLUMN `membership_id` VARCHAR(191) NULL;
+
+-- CreateIndex
+CREATE INDEX `memberships_user_id_card_type_id_idx` ON `memberships`(`user_id`, `card_type_id`);
+
+-- CreateIndex
+CREATE INDEX `orders_membership_id_idx` ON `orders`(`membership_id`);
+
+-- AddForeignKey
+ALTER TABLE `orders` ADD CONSTRAINT `orders_membership_id_fkey` FOREIGN KEY (`membership_id`) REFERENCES `memberships`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- Backfill purchased-times snapshot for existing times/trial cards
+UPDATE `memberships` `m`
+INNER JOIN `card_types` `ct` ON `ct`.`id` = `m`.`card_type_id`
+SET `m`.`total_times` = `ct`.`total_times`
+WHERE `ct`.`total_times` IS NOT NULL AND `m`.`total_times` IS NULL;
diff --git a/packages/server/prisma/schema.prisma b/packages/server/prisma/schema.prisma
index d338c8c..7d2bd7a 100644
--- a/packages/server/prisma/schema.prisma
+++ b/packages/server/prisma/schema.prisma
@@ -147,6 +147,7 @@ model Membership {
userId String @map("user_id")
cardTypeId String @map("card_type_id")
remainingTimes Int? @map("remaining_times")
+ totalTimes Int? @map("total_times")
startDate DateTime @map("start_date")
expireDate DateTime @map("expire_date")
status MembershipStatus @default(ACTIVE)
@@ -156,9 +157,11 @@ model Membership {
user User @relation(fields: [userId], references: [id])
cardType CardType @relation(fields: [cardTypeId], references: [id])
bookings Booking[]
+ orders Order[]
inviteRewardGrants InviteRewardGrant[]
@@index([userId])
+ @@index([userId, cardTypeId])
@@index([status])
@@map("memberships")
}
@@ -245,6 +248,7 @@ model Order {
id String @id @default(uuid())
userId String @map("user_id")
cardTypeId String @map("card_type_id")
+ membershipId String? @map("membership_id")
orderNo String @unique @map("order_no")
amount Decimal @db.Decimal(10, 0)
status OrderStatus @default(PENDING)
@@ -256,11 +260,13 @@ model Order {
user User @relation(fields: [userId], references: [id])
cardType CardType @relation(fields: [cardTypeId], references: [id])
+ membership Membership? @relation(fields: [membershipId], references: [id])
flashSaleOrder FlashSaleOrder?
inviteReferrals InviteReferral[]
@@index([userId])
@@index([status])
+ @@index([membershipId])
@@map("orders")
}
diff --git a/packages/server/src/membership/__tests__/membership-renewal.spec.ts b/packages/server/src/membership/__tests__/membership-renewal.spec.ts
new file mode 100644
index 0000000..8a460c6
--- /dev/null
+++ b/packages/server/src/membership/__tests__/membership-renewal.spec.ts
@@ -0,0 +1,295 @@
+import {
+ CardTypeCategory,
+ MembershipStatus,
+ RENEWAL_DAYS_THRESHOLD,
+ RENEWAL_TIMES_THRESHOLD,
+ computeMembershipGrant,
+ getMembershipRenewalHint,
+ pickRenewalTarget,
+} from '@mp-pilates/shared'
+import type { RenewalHintMembership } from '@mp-pilates/shared'
+
+const now = new Date('2026-06-01T00:00:00Z')
+
+const timesCard = {
+ type: CardTypeCategory.TIMES,
+ totalTimes: 10,
+ durationDays: 90,
+}
+
+const durationCard = {
+ type: CardTypeCategory.DURATION,
+ totalTimes: null,
+ durationDays: 30,
+}
+
+const trialCard = {
+ type: CardTypeCategory.TRIAL,
+ totalTimes: 1,
+ durationDays: 7,
+}
+
+function membership(overrides: Partial & { cardTypeId: string }): RenewalHintMembership {
+ return {
+ remainingTimes: null,
+ expireDate: '2026-12-01T00:00:00.000Z',
+ status: MembershipStatus.ACTIVE,
+ cardType: { type: CardTypeCategory.TIMES },
+ ...overrides,
+ }
+}
+
+describe('computeMembershipGrant', () => {
+ it('creates a fresh grant when there is no existing membership', () => {
+ const result = computeMembershipGrant({ existing: null, cardType: timesCard, now })
+
+ expect(result.isRenewal).toBe(false)
+ expect(result.remainingTimes).toBe(10)
+ expect(result.totalTimes).toBe(10)
+ expect(result.expireDate.getTime()).toBe(now.getTime() + 90 * 86_400_000)
+ })
+
+ it('always treats TRIAL as a new grant even if an old trial exists', () => {
+ const result = computeMembershipGrant({
+ existing: {
+ remainingTimes: 0,
+ totalTimes: 1,
+ expireDate: '2026-08-01T00:00:00.000Z',
+ status: MembershipStatus.ACTIVE,
+ },
+ cardType: trialCard,
+ now,
+ })
+
+ expect(result.isRenewal).toBe(false)
+ expect(result.remainingTimes).toBe(1)
+ expect(result.totalTimes).toBe(1)
+ })
+
+ it('stacks times and extends expireDate for an active TIMES card', () => {
+ const expireDate = new Date('2026-09-01T00:00:00Z')
+ const result = computeMembershipGrant({
+ existing: {
+ remainingTimes: 3,
+ totalTimes: 10,
+ expireDate,
+ status: MembershipStatus.ACTIVE,
+ },
+ cardType: timesCard,
+ now,
+ })
+
+ expect(result.isRenewal).toBe(true)
+ expect(result.remainingTimes).toBe(13)
+ expect(result.totalTimes).toBe(20)
+ expect(result.expireDate.getTime()).toBe(expireDate.getTime() + 90 * 86_400_000)
+ })
+
+ it('does not inherit leftover times when the TIMES card is expired', () => {
+ const result = computeMembershipGrant({
+ existing: {
+ remainingTimes: 4,
+ totalTimes: 10,
+ expireDate: '2026-01-01T00:00:00.000Z',
+ status: MembershipStatus.EXPIRED,
+ },
+ cardType: timesCard,
+ now,
+ })
+
+ expect(result.isRenewal).toBe(true)
+ expect(result.remainingTimes).toBe(10)
+ expect(result.totalTimes).toBe(10)
+ expect(result.expireDate.getTime()).toBe(now.getTime() + 90 * 86_400_000)
+ })
+
+ it('extends a still-active DURATION card from its current expireDate', () => {
+ const expireDate = new Date('2026-07-01T00:00:00Z')
+ const result = computeMembershipGrant({
+ existing: {
+ remainingTimes: null,
+ totalTimes: null,
+ expireDate,
+ status: MembershipStatus.ACTIVE,
+ },
+ cardType: durationCard,
+ now,
+ })
+
+ expect(result.isRenewal).toBe(true)
+ expect(result.remainingTimes).toBeNull()
+ expect(result.expireDate.getTime()).toBe(expireDate.getTime() + 30 * 86_400_000)
+ })
+
+ it('starts an expired DURATION card from now', () => {
+ const result = computeMembershipGrant({
+ existing: {
+ remainingTimes: null,
+ totalTimes: null,
+ expireDate: '2026-01-01T00:00:00.000Z',
+ status: MembershipStatus.EXPIRED,
+ },
+ cardType: durationCard,
+ now,
+ })
+
+ expect(result.isRenewal).toBe(true)
+ expect(result.expireDate.getTime()).toBe(now.getTime() + 30 * 86_400_000)
+ })
+})
+
+describe('getMembershipRenewalHint', () => {
+ it('prefers a TIMES card running low over a DURATION card near expiry', () => {
+ const hint = getMembershipRenewalHint(
+ [
+ membership({
+ cardTypeId: 'duration-1',
+ remainingTimes: null,
+ expireDate: new Date(now.getTime() + 3 * 86_400_000).toISOString(),
+ cardType: { type: CardTypeCategory.DURATION },
+ }),
+ membership({
+ cardTypeId: 'times-1',
+ remainingTimes: RENEWAL_TIMES_THRESHOLD,
+ expireDate: new Date(now.getTime() + 60 * 86_400_000).toISOString(),
+ cardType: { type: CardTypeCategory.TIMES },
+ }),
+ ],
+ now,
+ )
+
+ expect(hint).toMatchObject({
+ kind: 'times_low',
+ cardTypeId: 'times-1',
+ remainingTimes: RENEWAL_TIMES_THRESHOLD,
+ })
+ })
+
+ it('returns days_low when a DURATION card is within the threshold', () => {
+ const hint = getMembershipRenewalHint(
+ [
+ membership({
+ cardTypeId: 'duration-1',
+ remainingTimes: null,
+ expireDate: new Date(now.getTime() + RENEWAL_DAYS_THRESHOLD * 86_400_000).toISOString(),
+ cardType: { type: CardTypeCategory.DURATION },
+ }),
+ ],
+ now,
+ )
+
+ expect(hint).toMatchObject({
+ kind: 'days_low',
+ cardTypeId: 'duration-1',
+ daysLeft: RENEWAL_DAYS_THRESHOLD,
+ })
+ })
+
+ it('does not recommend renewing a TRIAL card when times run low', () => {
+ const hint = getMembershipRenewalHint(
+ [
+ membership({
+ cardTypeId: 'trial-1',
+ remainingTimes: 1,
+ expireDate: new Date(now.getTime() + 5 * 86_400_000).toISOString(),
+ cardType: { type: CardTypeCategory.TRIAL },
+ }),
+ ],
+ now,
+ )
+
+ expect(hint).toMatchObject({
+ kind: 'trial_low',
+ cardTypeId: null,
+ remainingTimes: 1,
+ })
+ })
+
+ it('recommends the most recently expired non-trial card', () => {
+ const hint = getMembershipRenewalHint(
+ [
+ membership({
+ cardTypeId: 'times-old',
+ remainingTimes: 0,
+ expireDate: '2026-01-01T00:00:00.000Z',
+ status: MembershipStatus.EXPIRED,
+ cardType: { type: CardTypeCategory.TIMES },
+ }),
+ membership({
+ cardTypeId: 'duration-latest',
+ remainingTimes: null,
+ expireDate: '2026-05-01T00:00:00.000Z',
+ status: MembershipStatus.EXPIRED,
+ cardType: { type: CardTypeCategory.DURATION },
+ }),
+ membership({
+ cardTypeId: 'trial-1',
+ remainingTimes: 0,
+ expireDate: '2026-05-15T00:00:00.000Z',
+ status: MembershipStatus.USED_UP,
+ cardType: { type: CardTypeCategory.TRIAL },
+ }),
+ ],
+ now,
+ )
+
+ expect(hint).toMatchObject({
+ kind: 'expired',
+ cardTypeId: 'duration-latest',
+ })
+ })
+
+ it('returns null when active cards are healthy', () => {
+ const hint = getMembershipRenewalHint(
+ [
+ membership({
+ cardTypeId: 'times-1',
+ remainingTimes: 8,
+ expireDate: new Date(now.getTime() + 60 * 86_400_000).toISOString(),
+ }),
+ ],
+ now,
+ )
+
+ expect(hint).toBeNull()
+ })
+})
+
+describe('pickRenewalTarget', () => {
+ it('picks the latest expiring membership of the same card type', () => {
+ const target = pickRenewalTarget(
+ [
+ membership({
+ cardTypeId: 'times-1',
+ expireDate: '2026-01-01T00:00:00.000Z',
+ }),
+ membership({
+ cardTypeId: 'times-1',
+ expireDate: '2026-08-01T00:00:00.000Z',
+ }),
+ membership({
+ cardTypeId: 'duration-1',
+ expireDate: '2026-12-01T00:00:00.000Z',
+ cardType: { type: CardTypeCategory.DURATION },
+ }),
+ ],
+ 'times-1',
+ )
+
+ expect(target?.expireDate).toBe('2026-08-01T00:00:00.000Z')
+ })
+
+ it('does not pick a TRIAL membership as a renewal target', () => {
+ const target = pickRenewalTarget(
+ [
+ membership({
+ cardTypeId: 'trial-1',
+ cardType: { type: CardTypeCategory.TRIAL },
+ }),
+ ],
+ 'trial-1',
+ )
+
+ expect(target).toBeNull()
+ })
+})
diff --git a/packages/server/src/membership/__tests__/membership.service.spec.ts b/packages/server/src/membership/__tests__/membership.service.spec.ts
index 98065cd..0ce6803 100644
--- a/packages/server/src/membership/__tests__/membership.service.spec.ts
+++ b/packages/server/src/membership/__tests__/membership.service.spec.ts
@@ -16,6 +16,7 @@ const mockTimesCardType = {
price: 150000,
originalPrice: null,
description: null,
+ coverUrl: null,
isActive: true,
sortOrder: 0,
createdAt: new Date('2024-01-01T00:00:00Z'),
@@ -31,6 +32,7 @@ const mockDurationCardType = {
price: 80000,
originalPrice: null,
description: null,
+ coverUrl: null,
isActive: true,
sortOrder: 1,
createdAt: new Date('2024-01-01T00:00:00Z'),
@@ -48,6 +50,7 @@ const mockActiveMembership = {
userId: 'user-001',
cardTypeId: mockTimesCardType.id,
remainingTimes: 5,
+ totalTimes: 10,
startDate: new Date('2024-01-01T00:00:00Z'),
expireDate: new Date('2099-12-31T00:00:00Z'),
status: MembershipStatus.ACTIVE,
@@ -61,6 +64,7 @@ const mockDurationMembership = {
id: 'mem-duration-001',
cardTypeId: mockDurationCardType.id,
remainingTimes: null,
+ totalTimes: null,
cardType: mockDurationCardType,
}
@@ -77,6 +81,7 @@ const mockPrismaService = {
findMany: jest.fn(),
findFirst: jest.fn(),
findUnique: jest.fn(),
+ create: jest.fn(),
update: jest.fn(),
},
}
@@ -300,6 +305,160 @@ describe('MembershipService', () => {
})
})
+ // ─── grantPurchasedCard ────────────────────────────────────────────────
+
+ describe('grantPurchasedCard()', () => {
+ const now = new Date('2026-06-01T00:00:00Z')
+ const tx = mockPrismaService as unknown as Parameters[0]
+
+ it('creates a new membership when the user has no card of that type', async () => {
+ mockPrismaService.membership.findFirst.mockResolvedValue(null)
+ const created = { ...mockActiveMembership, remainingTimes: 10, totalTimes: 10 }
+ mockPrismaService.membership.create.mockResolvedValue(created)
+
+ const result = await service.grantPurchasedCard(tx, 'user-001', mockTimesCardType, now)
+
+ expect(mockPrismaService.membership.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ userId: 'user-001',
+ cardTypeId: mockTimesCardType.id,
+ remainingTimes: 10,
+ totalTimes: 10,
+ status: MembershipStatus.ACTIVE,
+ }),
+ }),
+ )
+ expect(mockPrismaService.membership.update).not.toHaveBeenCalled()
+ expect(result.remainingTimes).toBe(10)
+ })
+
+ it('stacks remaining times and extends expireDate for an active TIMES card', async () => {
+ const existing = {
+ ...mockActiveMembership,
+ remainingTimes: 3,
+ totalTimes: 10,
+ expireDate: new Date('2026-09-01T00:00:00Z'),
+ }
+ mockPrismaService.membership.findFirst.mockResolvedValue(existing)
+ mockPrismaService.membership.update.mockResolvedValue({
+ ...existing,
+ remainingTimes: 13,
+ totalTimes: 20,
+ })
+
+ await service.grantPurchasedCard(tx, 'user-001', mockTimesCardType, now)
+
+ expect(mockPrismaService.membership.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { id: existing.id },
+ data: expect.objectContaining({
+ remainingTimes: 13,
+ totalTimes: 20,
+ status: MembershipStatus.ACTIVE,
+ }),
+ }),
+ )
+ const updateData = mockPrismaService.membership.update.mock.calls[0][0].data as {
+ expireDate: Date
+ }
+ expect(updateData.expireDate.getTime()).toBe(
+ existing.expireDate.getTime() + mockTimesCardType.durationDays * 86_400_000,
+ )
+ expect(mockPrismaService.membership.create).not.toHaveBeenCalled()
+ })
+
+ it('extends a DURATION card from the current expireDate when still active', async () => {
+ const existing = {
+ ...mockDurationMembership,
+ expireDate: new Date('2026-07-01T00:00:00Z'),
+ }
+ mockPrismaService.membership.findFirst.mockResolvedValue(existing)
+ mockPrismaService.membership.update.mockResolvedValue(existing)
+
+ await service.grantPurchasedCard(tx, 'user-001', mockDurationCardType, now)
+
+ expect(mockPrismaService.membership.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ remainingTimes: null,
+ totalTimes: null,
+ status: MembershipStatus.ACTIVE,
+ }),
+ }),
+ )
+ const updateData = mockPrismaService.membership.update.mock.calls[0][0].data as {
+ expireDate: Date
+ }
+ expect(updateData.expireDate.getTime()).toBe(
+ existing.expireDate.getTime() + mockDurationCardType.durationDays * 86_400_000,
+ )
+ })
+
+ it('does not carry leftover times when renewing an expired TIMES card', async () => {
+ const existing = {
+ ...mockActiveMembership,
+ remainingTimes: 4,
+ totalTimes: 10,
+ status: MembershipStatus.EXPIRED,
+ expireDate: new Date('2026-01-01T00:00:00Z'),
+ }
+ mockPrismaService.membership.findFirst.mockResolvedValue(existing)
+ mockPrismaService.membership.update.mockResolvedValue({
+ ...existing,
+ remainingTimes: 10,
+ status: MembershipStatus.ACTIVE,
+ })
+
+ await service.grantPurchasedCard(tx, 'user-001', mockTimesCardType, now)
+
+ expect(mockPrismaService.membership.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ remainingTimes: 10,
+ totalTimes: 10,
+ status: MembershipStatus.ACTIVE,
+ }),
+ }),
+ )
+ const updateData = mockPrismaService.membership.update.mock.calls[0][0].data as {
+ expireDate: Date
+ }
+ expect(updateData.expireDate.getTime()).toBe(
+ now.getTime() + mockTimesCardType.durationDays * 86_400_000,
+ )
+ })
+
+ it('always creates a new membership for TRIAL cards', async () => {
+ const trialCardType = {
+ ...mockTimesCardType,
+ id: 'ct-trial-001',
+ type: CardTypeCategory.TRIAL,
+ totalTimes: 1,
+ durationDays: 7,
+ }
+ mockPrismaService.membership.create.mockResolvedValue({
+ ...mockActiveMembership,
+ cardTypeId: trialCardType.id,
+ remainingTimes: 1,
+ totalTimes: 1,
+ })
+
+ await service.grantPurchasedCard(tx, 'user-001', trialCardType, now)
+
+ expect(mockPrismaService.membership.findFirst).not.toHaveBeenCalled()
+ expect(mockPrismaService.membership.create).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ cardTypeId: trialCardType.id,
+ remainingTimes: 1,
+ totalTimes: 1,
+ }),
+ }),
+ )
+ })
+ })
+
// ─── createCardType ────────────────────────────────────────────────────
describe('createCardType()', () => {
diff --git a/packages/server/src/membership/membership.service.ts b/packages/server/src/membership/membership.service.ts
index 53c5865..bcfe0d4 100644
--- a/packages/server/src/membership/membership.service.ts
+++ b/packages/server/src/membership/membership.service.ts
@@ -1,6 +1,6 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'
-import { CardType, Membership } from '@prisma/client'
-import { CardTypeCategory, MembershipStatus } from '@mp-pilates/shared'
+import { CardType, Membership, Prisma } from '@prisma/client'
+import { CardTypeCategory, MembershipStatus, computeMembershipGrant } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { CreateCardTypeDto } from './dto/create-card-type.dto'
import { UpdateCardTypeDto } from './dto/update-card-type.dto'
@@ -119,6 +119,55 @@ export class MembershipService {
return { ...updated, cardType: { ...updated.cardType } }
}
+ async grantPurchasedCard(
+ tx: Prisma.TransactionClient,
+ userId: string,
+ cardType: Pick,
+ now = new Date(),
+ ): Promise {
+ const existing =
+ cardType.type === CardTypeCategory.TRIAL
+ ? null
+ : await tx.membership.findFirst({
+ where: { userId, cardTypeId: cardType.id },
+ orderBy: { expireDate: 'desc' },
+ })
+
+ const grant = computeMembershipGrant({
+ existing,
+ cardType: {
+ type: cardType.type,
+ totalTimes: cardType.totalTimes,
+ durationDays: cardType.durationDays,
+ },
+ now,
+ })
+
+ if (!grant.isRenewal || !existing) {
+ return tx.membership.create({
+ data: {
+ userId,
+ cardTypeId: cardType.id,
+ startDate: now,
+ expireDate: grant.expireDate,
+ remainingTimes: grant.remainingTimes,
+ totalTimes: grant.totalTimes,
+ status: MembershipStatus.ACTIVE,
+ },
+ })
+ }
+
+ return tx.membership.update({
+ where: { id: existing.id },
+ data: {
+ remainingTimes: grant.remainingTimes,
+ totalTimes: grant.totalTimes,
+ expireDate: grant.expireDate,
+ status: MembershipStatus.ACTIVE,
+ },
+ })
+ }
+
// ─── Admin ─────────────────────────────────────────────────────────────────
async getAllCardTypes(): Promise {
diff --git a/packages/server/src/payment/__tests__/payment.service.spec.ts b/packages/server/src/payment/__tests__/payment.service.spec.ts
index 126cb03..4ff9f5d 100644
--- a/packages/server/src/payment/__tests__/payment.service.spec.ts
+++ b/packages/server/src/payment/__tests__/payment.service.spec.ts
@@ -6,6 +6,7 @@ import { PaymentService } from '../payment.service'
import { WechatPayService } from '../wechat-pay.service'
import { PrismaService } from '../../prisma/prisma.service'
import { InviteService } from '../../invite/invite.service'
+import { MembershipService } from '../../membership/membership.service'
// ─── Fixtures ─────────────────────────────────────────────────────────────────
@@ -19,6 +20,7 @@ const mockCardType = {
type: 'TIMES',
originalPrice: null,
description: null,
+ coverUrl: null,
sortOrder: 0,
createdAt: new Date(),
updatedAt: new Date(),
@@ -82,6 +84,11 @@ function buildPrismaMock() {
},
membership: {
create: jest.fn(),
+ findFirst: jest.fn(),
+ update: jest.fn(),
+ },
+ flashSaleOrder: {
+ updateMany: jest.fn(),
},
$transaction: jest.fn(),
}
@@ -109,6 +116,7 @@ describe('PaymentService', () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
PaymentService,
+ MembershipService,
{ provide: PrismaService, useValue: prisma },
{ provide: WechatPayService, useValue: wechat },
{ provide: InviteService, useValue: mockInviteService },
@@ -226,31 +234,33 @@ describe('PaymentService', () => {
})
prisma.order.findUnique.mockResolvedValue(pendingOrder)
prisma.cardType.findUnique.mockResolvedValue(mockCardType)
- prisma.$transaction.mockResolvedValue([])
+ prisma.membership.findFirst.mockResolvedValue(null)
+ prisma.membership.create.mockResolvedValue({
+ id: 'mem-new-1',
+ userId: pendingOrder.userId,
+ cardTypeId: pendingOrder.cardTypeId,
+ remainingTimes: mockCardType.totalTimes,
+ totalTimes: mockCardType.totalTimes,
+ status: MembershipStatus.ACTIVE,
+ })
+ prisma.$transaction.mockImplementation(async (fn: (tx: typeof prisma) => Promise) => fn(prisma))
})
- it('marks order as PAID and creates membership on valid callback', async () => {
+ it('marks order as PAID and grants a new membership on valid callback', async () => {
const result = await service.handleWxNotify(headers, successBody)
- // $transaction called once with an array of two operations
expect(prisma.$transaction).toHaveBeenCalledTimes(1)
- const [transactionOps] = prisma.$transaction.mock.calls[0] as [unknown[]]
- expect(transactionOps).toHaveLength(2)
-
- // order.update was called with PAID status and transaction id
expect(prisma.order.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
status: OrderStatus.PAID,
wxTransactionId: successBody.transaction_id,
+ membershipId: 'mem-new-1',
}),
}),
)
-
- // membership.create was called
expect(prisma.membership.create).toHaveBeenCalledTimes(1)
expect(mockInviteService.recordTrialOrderPaid).toHaveBeenCalledWith(pendingOrder.id)
-
expect(result).toContain('SUCCESS')
})
@@ -275,7 +285,7 @@ describe('PaymentService', () => {
const expectedExpireMs =
membershipData.startDate.getTime() + mockCardType.durationDays * 86_400_000
- expect(membershipData.expireDate.getTime()).toBeCloseTo(expectedExpireMs, -2) // within 100ms
+ expect(membershipData.expireDate.getTime()).toBeCloseTo(expectedExpireMs, -2)
expect(membershipData.startDate.getTime()).toBeGreaterThanOrEqual(beforeCall)
})
@@ -285,14 +295,15 @@ describe('PaymentService', () => {
expect(prisma.membership.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
- remainingTimes: mockCardType.totalTimes, // 10
+ remainingTimes: mockCardType.totalTimes,
+ totalTimes: mockCardType.totalTimes,
}),
}),
)
})
it('creates membership with null remainingTimes for duration-based cardType', async () => {
- const durationCardType = { ...mockCardType, totalTimes: null }
+ const durationCardType = { ...mockCardType, totalTimes: null, type: 'DURATION' }
prisma.cardType.findUnique.mockResolvedValue(durationCardType)
await service.handleWxNotify(headers, successBody)
@@ -301,6 +312,46 @@ describe('PaymentService', () => {
expect.objectContaining({
data: expect.objectContaining({
remainingTimes: null,
+ totalTimes: null,
+ }),
+ }),
+ )
+ })
+
+ it('renews an existing same-type membership instead of creating another', async () => {
+ const existingMembership = {
+ id: 'mem-existing-1',
+ userId: pendingOrder.userId,
+ cardTypeId: pendingOrder.cardTypeId,
+ remainingTimes: 2,
+ totalTimes: 10,
+ expireDate: new Date('2099-01-01T00:00:00Z'),
+ status: MembershipStatus.ACTIVE,
+ }
+ prisma.membership.findFirst.mockResolvedValue(existingMembership)
+ prisma.membership.update.mockResolvedValue({
+ ...existingMembership,
+ remainingTimes: 12,
+ totalTimes: 20,
+ })
+
+ await service.handleWxNotify(headers, successBody)
+
+ expect(prisma.membership.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ where: { id: 'mem-existing-1' },
+ data: expect.objectContaining({
+ remainingTimes: 12,
+ totalTimes: 20,
+ status: MembershipStatus.ACTIVE,
+ }),
+ }),
+ )
+ expect(prisma.membership.create).not.toHaveBeenCalled()
+ expect(prisma.order.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ data: expect.objectContaining({
+ membershipId: 'mem-existing-1',
}),
}),
)
diff --git a/packages/server/src/payment/payment.module.ts b/packages/server/src/payment/payment.module.ts
index 2f197ac..5fcd0ab 100644
--- a/packages/server/src/payment/payment.module.ts
+++ b/packages/server/src/payment/payment.module.ts
@@ -4,9 +4,10 @@ import { PaymentService } from './payment.service'
import { PaymentController } from './payment.controller'
import { WechatPayService } from './wechat-pay.service'
import { InviteModule } from '../invite/invite.module'
+import { MembershipModule } from '../membership/membership.module'
@Module({
- imports: [PrismaModule, InviteModule],
+ imports: [PrismaModule, InviteModule, MembershipModule],
controllers: [PaymentController],
providers: [PaymentService, WechatPayService],
exports: [PaymentService, WechatPayService],
diff --git a/packages/server/src/payment/payment.service.ts b/packages/server/src/payment/payment.service.ts
index 7b21d89..613dd3c 100644
--- a/packages/server/src/payment/payment.service.ts
+++ b/packages/server/src/payment/payment.service.ts
@@ -5,10 +5,11 @@ import {
NotFoundException,
} from '@nestjs/common'
import { CardType, Order } from '@prisma/client'
-import { MembershipStatus, OrderStatus, FlashSaleOrderStatus } from '@mp-pilates/shared'
+import { OrderStatus, FlashSaleOrderStatus } from '@mp-pilates/shared'
import { PrismaService } from '../prisma/prisma.service'
import { WechatPayService, WxPaymentParams } from './wechat-pay.service'
import { InviteService } from '../invite/invite.service'
+import { MembershipService } from '../membership/membership.service'
export interface CreateOrderResult {
order: Order
@@ -30,6 +31,7 @@ export class PaymentService {
private readonly prisma: PrismaService,
private readonly wechatPayService: WechatPayService,
private readonly inviteService: InviteService,
+ private readonly membershipService: MembershipService,
) {}
// ─── User: create order ────────────────────────────────────────────────────
@@ -118,32 +120,29 @@ export class PaymentService {
}
const now = new Date()
- const expireDate = new Date(now.getTime() + cardType.durationDays * 86_400_000)
- await this.prisma.$transaction([
- this.prisma.order.update({
+ await this.prisma.$transaction(async (tx) => {
+ const membership = await this.membershipService.grantPurchasedCard(
+ tx,
+ existingOrder.userId,
+ cardType,
+ now,
+ )
+
+ await tx.order.update({
where: { id: existingOrder.id },
data: {
status: OrderStatus.PAID,
wxTransactionId: notification.wxTransactionId,
paidAt: now,
+ membershipId: membership.id,
},
- }),
- this.prisma.membership.create({
- data: {
- userId: existingOrder.userId,
- cardTypeId: existingOrder.cardTypeId,
- startDate: now,
- expireDate,
- remainingTimes: cardType.totalTimes ?? null,
- status: MembershipStatus.ACTIVE,
- },
- }),
- ])
+ })
+ })
await this.inviteService.recordTrialOrderPaid(existingOrder.id)
- this.logger.log(`Order PAID and Membership created: orderNo=${notification.orderNo}`)
+ this.logger.log(`Order PAID and membership granted: orderNo=${notification.orderNo}`)
// ── Flash sale order: mark as PAID ──
if (existingOrder.flashSaleId) {
diff --git a/packages/server/src/user/user.service.ts b/packages/server/src/user/user.service.ts
index 87cb420..311602b 100644
--- a/packages/server/src/user/user.service.ts
+++ b/packages/server/src/user/user.service.ts
@@ -440,7 +440,7 @@ export class UserService {
}
return this.prisma.membership.create({
- data: { userId, ...data },
+ data: { userId, totalTimes: dto.remainingTimes ?? null, ...data },
include: { cardType: true },
})
}
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 8f5ba2b..dffa5af 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -30,6 +30,27 @@ export {
SUBSCRIPTION_MESSAGE_REQUEST_RESULTS,
} from './constants'
+export {
+ RENEWAL_TIMES_THRESHOLD,
+ RENEWAL_DAYS_THRESHOLD,
+ MEMBERSHIP_DAY_MS,
+ addDurationDays,
+ getMembershipDaysLeft,
+ canRenewCardType,
+ computeMembershipGrant,
+ pickRenewalTarget,
+ getMembershipRenewalHint,
+} from './membership-renewal'
+
+export type {
+ MembershipGrantExisting,
+ MembershipGrantCardType,
+ MembershipGrantResult,
+ MembershipRenewalHintKind,
+ MembershipRenewalHint,
+ RenewalHintMembership,
+} from './membership-renewal'
+
// Types
export type {
User,
diff --git a/packages/shared/src/membership-renewal.ts b/packages/shared/src/membership-renewal.ts
new file mode 100644
index 0000000..5757a9e
--- /dev/null
+++ b/packages/shared/src/membership-renewal.ts
@@ -0,0 +1,203 @@
+import { CardTypeCategory, MembershipStatus } from './enums'
+
+export const RENEWAL_TIMES_THRESHOLD = 2
+export const RENEWAL_DAYS_THRESHOLD = 7
+export const MEMBERSHIP_DAY_MS = 86_400_000
+
+export interface MembershipGrantExisting {
+ readonly remainingTimes: number | null
+ readonly totalTimes: number | null
+ readonly expireDate: Date | string
+ readonly status: string
+}
+
+export interface MembershipGrantCardType {
+ readonly type: string
+ readonly totalTimes: number | null
+ readonly durationDays: number
+}
+
+export interface MembershipGrantResult {
+ readonly remainingTimes: number | null
+ readonly expireDate: Date
+ readonly totalTimes: number | null
+ readonly isRenewal: boolean
+}
+
+export type MembershipRenewalHintKind = 'times_low' | 'days_low' | 'trial_low' | 'expired'
+
+export interface MembershipRenewalHint {
+ readonly kind: MembershipRenewalHintKind
+ readonly cardTypeId: string | null
+ readonly remainingTimes: number | null
+ readonly daysLeft: number | null
+}
+
+export interface RenewalHintMembership {
+ readonly cardTypeId: string
+ readonly remainingTimes: number | null
+ readonly expireDate: Date | string
+ readonly status: MembershipStatus
+ readonly cardType: {
+ readonly type: CardTypeCategory
+ }
+}
+
+export function addDurationDays(from: Date, durationDays: number): Date {
+ return new Date(from.getTime() + durationDays * MEMBERSHIP_DAY_MS)
+}
+
+export function getMembershipDaysLeft(expireDate: Date | string, now: Date = new Date()): number {
+ return Math.ceil((new Date(expireDate).getTime() - now.getTime()) / MEMBERSHIP_DAY_MS)
+}
+
+export function canRenewCardType(type: CardTypeCategory): boolean {
+ return type !== CardTypeCategory.TRIAL
+}
+
+export function computeMembershipGrant(params: {
+ readonly existing: MembershipGrantExisting | null
+ readonly cardType: MembershipGrantCardType
+ readonly now: Date
+}): MembershipGrantResult {
+ const { existing, cardType, now } = params
+
+ if (!existing || cardType.type === CardTypeCategory.TRIAL) {
+ return {
+ remainingTimes: cardType.totalTimes ?? null,
+ expireDate: addDurationDays(now, cardType.durationDays),
+ totalTimes: cardType.totalTimes ?? null,
+ isRenewal: false,
+ }
+ }
+
+ const existingExpire = new Date(existing.expireDate)
+ const isExpired =
+ existing.status === MembershipStatus.EXPIRED || existingExpire.getTime() <= now.getTime()
+ const baseDate = existingExpire.getTime() > now.getTime() ? existingExpire : now
+ const expireDate = addDurationDays(baseDate, cardType.durationDays)
+
+ if (cardType.totalTimes == null) {
+ return {
+ remainingTimes: null,
+ expireDate,
+ totalTimes: null,
+ isRenewal: true,
+ }
+ }
+
+ if (isExpired) {
+ return {
+ remainingTimes: cardType.totalTimes,
+ expireDate: addDurationDays(now, cardType.durationDays),
+ totalTimes: cardType.totalTimes,
+ isRenewal: true,
+ }
+ }
+
+ return {
+ remainingTimes: (existing.remainingTimes ?? 0) + cardType.totalTimes,
+ expireDate,
+ totalTimes: (existing.totalTimes ?? existing.remainingTimes ?? 0) + cardType.totalTimes,
+ isRenewal: true,
+ }
+}
+
+export function pickRenewalTarget(
+ memberships: readonly T[],
+ cardTypeId: string,
+): T | null {
+ const matches = memberships
+ .filter((m) => m.cardTypeId === cardTypeId && canRenewCardType(m.cardType.type))
+ .slice()
+ .sort((a, b) => new Date(b.expireDate).getTime() - new Date(a.expireDate).getTime())
+
+ return matches[0] ?? null
+}
+
+function isUsableMembership(membership: RenewalHintMembership, now: Date): boolean {
+ if (membership.status !== MembershipStatus.ACTIVE) return false
+ if (new Date(membership.expireDate).getTime() <= now.getTime()) return false
+ return membership.remainingTimes === null || membership.remainingTimes > 0
+}
+
+export function getMembershipRenewalHint(
+ memberships: readonly RenewalHintMembership[],
+ now: Date = new Date(),
+): MembershipRenewalHint | null {
+ const usable = memberships.filter((m) => isUsableMembership(m, now))
+
+ if (usable.length > 0) {
+ const lowTimes = usable.filter(
+ (m) =>
+ m.cardType.type === CardTypeCategory.TIMES &&
+ m.remainingTimes !== null &&
+ m.remainingTimes <= RENEWAL_TIMES_THRESHOLD,
+ )
+ if (lowTimes.length > 0) {
+ const target = lowTimes.reduce((lowest, current) =>
+ (current.remainingTimes ?? 0) < (lowest.remainingTimes ?? 0) ? current : lowest,
+ )
+ return {
+ kind: 'times_low',
+ cardTypeId: target.cardTypeId,
+ remainingTimes: target.remainingTimes,
+ daysLeft: getMembershipDaysLeft(target.expireDate, now),
+ }
+ }
+
+ const lowDays = usable.filter(
+ (m) =>
+ m.cardType.type === CardTypeCategory.DURATION &&
+ getMembershipDaysLeft(m.expireDate, now) <= RENEWAL_DAYS_THRESHOLD,
+ )
+ if (lowDays.length > 0) {
+ const target = lowDays.reduce((lowest, current) => {
+ const currentDays = getMembershipDaysLeft(current.expireDate, now)
+ const lowestDays = getMembershipDaysLeft(lowest.expireDate, now)
+ return currentDays < lowestDays ? current : lowest
+ })
+ return {
+ kind: 'days_low',
+ cardTypeId: target.cardTypeId,
+ remainingTimes: null,
+ daysLeft: getMembershipDaysLeft(target.expireDate, now),
+ }
+ }
+
+ const lowTrial = usable.filter(
+ (m) =>
+ m.cardType.type === CardTypeCategory.TRIAL &&
+ m.remainingTimes !== null &&
+ m.remainingTimes <= RENEWAL_TIMES_THRESHOLD,
+ )
+ if (lowTrial.length > 0) {
+ const target = lowTrial.reduce((lowest, current) =>
+ (current.remainingTimes ?? 0) < (lowest.remainingTimes ?? 0) ? current : lowest,
+ )
+ return {
+ kind: 'trial_low',
+ cardTypeId: null,
+ remainingTimes: target.remainingTimes,
+ daysLeft: getMembershipDaysLeft(target.expireDate, now),
+ }
+ }
+
+ return null
+ }
+
+ if (memberships.length === 0) return null
+
+ const renewable = memberships
+ .filter((m) => canRenewCardType(m.cardType.type))
+ .slice()
+ .sort((a, b) => new Date(b.expireDate).getTime() - new Date(a.expireDate).getTime())
+
+ const recommended = renewable[0]
+ return {
+ kind: 'expired',
+ cardTypeId: recommended?.cardTypeId ?? null,
+ remainingTimes: recommended?.remainingTimes ?? null,
+ daysLeft: recommended ? getMembershipDaysLeft(recommended.expireDate, now) : null,
+ }
+}
diff --git a/packages/shared/src/types/membership.ts b/packages/shared/src/types/membership.ts
index ed103c2..7b2592f 100644
--- a/packages/shared/src/types/membership.ts
+++ b/packages/shared/src/types/membership.ts
@@ -6,6 +6,7 @@ export interface Membership {
readonly userId: string
readonly cardTypeId: string
readonly remainingTimes: number | null
+ readonly totalTimes: number | null
readonly startDate: string
readonly expireDate: string
readonly status: MembershipStatus
diff --git a/packages/shared/src/types/order.ts b/packages/shared/src/types/order.ts
index 277168e..164acd9 100644
--- a/packages/shared/src/types/order.ts
+++ b/packages/shared/src/types/order.ts
@@ -4,6 +4,7 @@ export interface Order {
readonly id: string
readonly userId: string
readonly cardTypeId: string
+ readonly membershipId: string | null
readonly orderNo: string
readonly amount: number
readonly status: OrderStatus