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