feat(contract): 支持月卡到期补差升级次卡条款
This commit is contained in:
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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('升级后的权益与确认:本期月卡已使用课时按本合同第三条及预约、课时扣减记录核对,计入目标次卡的已使用课时,升级后剩余课时 = 目标次卡总课时 − 本期月卡已使用课时,不再重复扣减或重复使用原月卡权益。目标次卡自补款及双方书面确认均完成之日起计算该次卡公示的完整有效期。乙方应在收取补款前,向甲方说明并由双方书面确认原月卡及合同、目标次卡、抵扣金额、补款金额、已用与剩余课时、有效期起止日期,留存补充协议或其他书面确认及收款凭证;原月卡剩余权益在升级生效时并入目标次卡。'),
|
||||
]),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user