feat(contract): 支持月卡到期补差升级次卡条款

This commit is contained in:
richarjiang
2026-10-29 17:31:46 +08:00
parent 0cba1a0840
commit 20ef49d221
5 changed files with 98 additions and 6 deletions

View File

@@ -18,16 +18,30 @@
## 合同条款要点
条款由 `server/src/contract/contract-template.ts` 按卡种动态生成,模板版本 `2026-10-v1`。依据《消费者权益保护法实施条例》第 22 条及《最高人民法院关于审理预付式消费民事纠纷案件适用法律若干问题的解释》(2025-05-01 施行):
条款由 `server/src/contract/contract-template.ts` 按卡种动态生成,模板版本 `2026-10-v2`。依据《消费者权益保护法实施条例》第 22 条及《最高人民法院关于审理预付式消费民事纠纷案件适用法律若干问题的解释》(2025-05-01 施行):
- 付款 7 日内且未上课,全额退款(续费同样适用)。
- 学员原因退费:实付 − 已用课时 × 单节标准课价,结果小于零按零,不收手续费;不限次卡按剩余天数比例退。
- 场馆原因退费:按本卡实付单价折算已用课时,并依法计息。
- 允许整体转让、免费延期 1 次(≤ 30 天)、伤病孕期可暂停;停业或迁址提前 30 日公告。
- 月卡到期后可联系馆主补差升级同类课程多次卡:按本期月卡实付且未退还的金额抵扣,已用课时计入目标次卡已用课时,新有效期从补款及双方书面确认均完成之日起算;升级不影响依法退费的权利。
- 删除了模板中「最终解释权归场馆」「到期不退任何费用」「转卡收 10%」等可能被认定无效的格式条款;与学员重大利害相关的条款加粗提示。
单节标准课价在「合同签署设置」中配置,未配置时取在售单次卡价格。
## 月卡到期补差升级(2026-10-29)
- 第二条新增月卡专属条款,并在到期、合同终止条款中保留升级及依法结算的权利,不再使用「到期一律不退费」表述。
- 目标为升级时在售的同类课程多次卡,以办理时公示售价计算差价,售价不低于可抵扣金额、总课时多于原月卡已用课时;不另收升级手续费,同一期月卡金额不得重复抵扣。
- 示例:月卡实付 1,000 元、已用 3 节,升级售价 2,000 元的 10 次卡,补款 1,000 元,剩余 7 节。不是另赠一张完整的 10 次卡。
- 办理前须书面确认原月卡/合同、目标次卡、抵扣与补款金额、已用及剩余课时、有效期起止;原月卡权益并入次卡,不重复使用。本次只更新合同文本,不新增自动补款、改卡或合同补充协议功能;馆主实际办理时应留存双方书面确认及收款凭证。
### 仅更新本次条款的发布范围
- 合同功能已上线时,只需构建并发布 server;无 shared 类型、接口、小程序界面及数据库变更,无需为本次条款重新发布小程序。首次上线合同功能仍需完成下方原有迁移和小程序发布步骤。
- 购卡范本、新合同及已有待签合同再次加载时使用新版正文;若用户停留在旧正文,签署时的 SHA-256 校验会拒绝提交,需重新加载阅读后签署。
- 签署时同步保存实际正文的模板版本,避免旧待签记录仍标记 v1、正文却为 v2。已签合同的正文、版本、摘要及 PDF 保持签署时快照,不自动追加条款;如需适用于已签合同,双方另行书面确认补充约定。
## 配置与发布
1. 应用迁移 `20261028090000_membership_contracts`(仅新增表与 `orders.contract_accepted_at` 列),生成 Prisma Client,再发布 shared/server。
@@ -57,3 +71,9 @@
- 服务端全量测试通过:38 个套件、605 项测试;新增合同模板、合同服务及预约拦截用例。
- shared/server 构建、小程序类型检查与微信小程序生产构建通过;本地生成样例 PDF 并逐页检查排版。
- 未在本地连接数据库做端到端验证(本地 `.env` 指向生产库),需在部署后按上述流程真机验收。
## 条款更新验证(2026-10-29)
- 服务端 38 个测试套件、610 项测试通过,服务端构建通过。
- 覆盖有限次/不限次月卡升级条款、到期权利保留、旧待签合同签署时保存新版版本,以及已签旧版正文和 PDF 不受模板更新影响。
- 本次未连接数据库或部署;发布后检查月卡合同预览与待签正文,已签合同应仍展示原文。

View File

@@ -1,5 +1,6 @@
import { MembershipContractSource } from '@mp-pilates/shared'
import {
CONTRACT_TEMPLATE_VERSION,
buildContractDocument,
formatChinaDateTime,
formatYuan,
@@ -112,6 +113,38 @@ describe('buildContractDocument', () => {
expect(unlimited).not.toContain('乙方确认预约时扣除 1 节课时')
})
it.each([10, null])('grants expired monthly cards a price-difference upgrade with used lessons carried over (%s lessons)', lessonCount => {
const document = render({}, { kind: 'MONTHLY', lessonCount, durationDays: 30 })
const rights = document.sections[1]
expect(document.templateVersion).toBe(CONTRACT_TEMPLATE_VERSION)
expect(rights.clauses[5]).toEqual(expect.objectContaining({
emphasis: true,
text: expect.stringContaining('6. 月卡到期补差升级'),
}))
expect(rights.clauses[5].text).toContain('实付且未退还的金额全额抵扣')
expect(rights.clauses[5].text).toContain('不得以办理升级为由限制甲方依法享有的退费等权利')
expect(rights.clauses[6]).toEqual(expect.objectContaining({
emphasis: true,
text: expect.stringContaining('升级后剩余课时 = 目标次卡总课时 − 本期月卡已使用课时'),
}))
expect(rights.clauses[6].text).toContain('自补款及双方书面确认均完成之日起')
expect(rights.clauses[6].text).toContain('收取补款前')
expect(rights.clauses[0].text).toContain('依本条第 6、7 款办理到期升级的除外')
expect(document.sections[0].clauses[2].text).toContain('不影响甲方依第二条第 6、7 款办理月卡到期升级')
})
it('does not apply monthly upgrades to times cards or exclude statutory refunds after expiry', () => {
const document = render()
expect(document.sections[1].clauses).toHaveLength(5)
expect(allText(document)).not.toContain('月卡到期升级')
expect(allText(document)).not.toContain('月卡到期补差升级')
for (const kind of ['MONTHLY', 'MULTI_TIMES'] as const) {
const text = allText(render({}, { kind }))
expect(text).not.toContain('也不予退费')
expect(text).toContain('有效期届满不当然免除乙方依法应承担的退费、结算等义务')
}
})
it('marks missing party information as pending and renders legacy terms', () => {
const document = render(
{

View File

@@ -2,7 +2,7 @@ import { BadRequestException, ConflictException, NotFoundException } from '@nest
import { Decimal } from '@prisma/client/runtime/library'
import { MembershipContractSource, MembershipContractStatus } from '@mp-pilates/shared'
import { ContractService, decodeSignaturePng } from '../contract.service'
import { buildContractDocument, hashContractDocument } from '../contract-template'
import { CONTRACT_TEMPLATE_VERSION, buildContractDocument, hashContractDocument } from '../contract-template'
import { DEFAULT_CONTRACT_SEAL } from '../contract-seal'
const PNG = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(800, 1)])
@@ -236,6 +236,7 @@ describe('ContractService.sign', () => {
orderId: 'order-1',
source: 'PURCHASE',
status: 'PENDING_SIGN',
templateVersion: '2026-10-v1',
requireBeforeBooking: true,
terms,
partyName: '张三',
@@ -284,6 +285,7 @@ describe('ContractService.sign', () => {
expect(update.where).toEqual({ id: 'contract-1', status: MembershipContractStatus.PENDING_SIGN })
expect(update.data).toMatchObject({
status: MembershipContractStatus.SIGNED,
templateVersion: CONTRACT_TEMPLATE_VERSION,
contentSha256: expectedHash,
signerIp: '1.2.3.4',
partyBSignatureImage: PNG,
@@ -346,6 +348,40 @@ describe('ContractService.sign', () => {
await expect(service.exportPdf('contract-1', { id: 'user-1', isAdmin: false }, meta)).rejects.toBeInstanceOf(BadRequestException)
expect(pdf.render).not.toHaveBeenCalled()
})
it('preserves signed v1 documents and PDFs after a template update', async () => {
const { service, prisma, pdf } = buildService()
const frozenDocument = {
...buildContractDocument({
contractNo: contract.contractNo,
source: MembershipContractSource.PURCHASE,
terms: terms as never,
partyA: { realName: '张三', phone: '13800000000', openid: contract.user.openid },
partyB: { ...party, premisesType: 'LEASED', leaseEndDate: null },
cancelHoursLimit: 2,
}),
templateVersion: '2026-10-v1',
sections: [{ title: '旧版已签条款', clauses: [{ text: '1. 签署时的约定。' }] }],
}
const contentSha256 = hashContractDocument(frozenDocument)
prisma.membershipContract.findUnique.mockResolvedValue({
...contract,
status: MembershipContractStatus.SIGNED,
document: frozenDocument,
contentSha256,
signatureImage: PNG,
partyBSignatureImage: PNG,
signedAt: new Date('2026-10-28T04:00:00.000Z'),
})
const viewer = { id: 'user-1', isAdmin: false }
const detail = await service.getDetail('contract-1', viewer)
expect(detail.document).toEqual(frozenDocument)
expect(detail.contentSha256).toBe(contentSha256)
await service.exportPdf('contract-1', viewer, meta)
expect(pdf.render).toHaveBeenCalledWith(expect.objectContaining({ document: frozenDocument, contentSha256 }))
expect(prisma.contractParty.findFirst).not.toHaveBeenCalled()
expect(prisma.membershipContract.updateMany).not.toHaveBeenCalled()
})
})
describe('decodeSignaturePng', () => {

View File

@@ -9,7 +9,7 @@ import {
type ContractSection,
} from '@mp-pilates/shared'
export const CONTRACT_TEMPLATE_VERSION = '2026-10-v1'
export const CONTRACT_TEMPLATE_VERSION = '2026-10-v2'
const CHINA_OFFSET_MS = 8 * 3600 * 1000
const PENDING_TEXT = '(待填写)'
@@ -269,7 +269,7 @@ function buildSections(input: ContractRenderInput): ContractSection[] {
source === MembershipContractSource.LEGACY
? '甲方在本合同签署前已向乙方购买会员卡,双方确认此前的付款、预约、上课及课时扣减记录真实有效;自本合同签署之日起,双方就剩余权益按本合同履行。'
: '甲方在本合同签署前已完成付款的,付款时间及会员卡权益生效时间以本合同「会员卡信息」所载为准;签署前已发生的预约、上课及课时扣减记录,双方确认有效并适用本合同。',
'本合同履行期限自会员卡权益生效之时起,至权益截止时间届满或课时全部使用完毕之时止(以先到者为准);依本合同办理退费或转让完毕的,本合同自办理完毕之日起终止。',
`本合同履行期限自会员卡权益生效之时起,至权益截止时间届满或课时全部使用完毕之时止(以先到者为准);依本合同办理退费或转让完毕的,本合同自办理完毕之日起终止。合同终止不影响双方依法进行退费、结算及承担相应责任${isMonthly ? ',也不影响甲方依第二条第 6、7 款办理月卡到期升级' : ''}。`,
'甲方可随时在乙方小程序「个人中心 - 我的合同」查看、下载本合同及签署记录。',
]),
},
@@ -277,14 +277,16 @@ function buildSections(input: ContractRenderInput): ContractSection[] {
title: '第二条 会员卡权益与有效期',
clauses: clauses([
isMonthly
? `本卡为月卡,每期有效期 ${terms.durationDays} 天,每期课时为${unlimited ? '有效期内不限次数' : ` ${terms.lessonCount} 节`}。每期课时仅限当期使用,未使用的课时不结转至下一期。`
? `本卡为月卡,每期有效期 ${terms.durationDays} 天,每期课时为${unlimited ? '有效期内不限次数' : ` ${terms.lessonCount} 节`}。每期课时仅限当期使用,未使用的课时不结转至下一期;依本条第 6、7 款办理到期升级的除外。`
: `本卡为多次卡,共含课时 ${terms.lessonCount ?? 0} 节,自付款确认时起生效,有效期 ${terms.durationDays} 天,具体起止时间以「会员卡信息」所载为准。`,
isMonthly
? '上一期月卡尚未到期时购买的,新一期自上一期届满时起生效;当期课时用完后,甲方可在小程序中确认提前启用下一期,下一期自启用时起计算完整期限。各期生效与截止时间以小程序记录为准。'
: '在有效期内续购同一卡种的,新增课时并入原会员卡,有效期按续购时小程序公示的规则相应延长,并以续购时签署的合同为准;已过期会员卡的剩余课时不并入新购卡。',
bold('因甲方自身原因未在有效期内使用的课时,在有效期届满后失效,乙方不再提供相应服务,也不予退费;甲方在有效期届满前已按第七条申请延期或暂停的除外。'),
bold(`有效期届满后,甲方不能直接使用本卡预约课程;甲方已按第七条办理延期或暂停的,按调整后的有效期执行。有效期届满不当然免除乙方依法应承担的退费、结算等义务,甲方可依第六条及法律规定主张相关权利${isMonthly ? ',也可依本条第 6、7 款办理到期升级' : ''}。`),
'乙方在小程序中实时展示剩余课时、有效期及预约、上课记录,甲方可随时查询。',
'本会员卡为记名卡,仅限甲方本人使用,甲方不得将会员卡借予他人使用。',
isMonthly && bold('月卡到期补差升级:本期月卡有效期届满后,甲方有权联系乙方,将本期月卡升级为乙方在售的同类课程多次卡。以升级办理时目标次卡的公示售价为准,本期月卡实付且未退还的金额全额抵扣该售价,甲方补交两者差额,乙方不另收升级手续费。目标次卡售价应不低于可抵扣金额,且总课时应多于本期月卡已使用课时;同一期月卡金额不得重复抵扣。甲方可自主选择是否升级,乙方不得以办理升级为由限制甲方依法享有的退费等权利。'),
isMonthly && bold('升级后的权益与确认:本期月卡已使用课时按本合同第三条及预约、课时扣减记录核对,计入目标次卡的已使用课时,升级后剩余课时 = 目标次卡总课时 − 本期月卡已使用课时,不再重复扣减或重复使用原月卡权益。目标次卡自补款及双方书面确认均完成之日起计算该次卡公示的完整有效期。乙方应在收取补款前,向甲方说明并由双方书面确认原月卡及合同、目标次卡、抵扣金额、补款金额、已用与剩余课时、有效期起止日期,留存补充协议或其他书面确认及收款凭证;原月卡剩余权益在升级生效时并入目标次卡。'),
]),
},
{

View File

@@ -380,6 +380,7 @@ export class ContractService {
where: { id, status: MembershipContractStatus.PENDING_SIGN },
data: {
status: MembershipContractStatus.SIGNED,
templateVersion: document.templateVersion,
document: document as unknown as Prisma.InputJsonValue,
contentSha256,
signatureImage: signature,