BIN
packages/server/assets/fonts/NotoSansSC-Regular.otf
Normal file
BIN
packages/server/assets/fonts/NotoSansSC-Regular.otf
Normal file
Binary file not shown.
@@ -31,6 +31,7 @@
|
||||
"nest-winston": "^1.10.2",
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"pdfkit": "^0.17.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"winston": "^3.19.0",
|
||||
@@ -43,6 +44,7 @@
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/passport-jwt": "^4.0.1",
|
||||
"@types/pdfkit": "^0.17.7",
|
||||
"jest": "^29.7.0",
|
||||
"prisma": "^5.19.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE `orders` ADD COLUMN `contract_accepted_at` DATETIME(3) NULL;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `contract_parties` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`business_name` VARCHAR(100) NOT NULL DEFAULT '',
|
||||
`brand_name` VARCHAR(60) NOT NULL DEFAULT '',
|
||||
`credit_code` VARCHAR(18) NOT NULL DEFAULT '',
|
||||
`operator_name` VARCHAR(30) NOT NULL DEFAULT '',
|
||||
`phone` VARCHAR(20) NOT NULL DEFAULT '',
|
||||
`address` VARCHAR(200) NOT NULL DEFAULT '',
|
||||
`premises_type` VARCHAR(10) NOT NULL DEFAULT 'LEASED',
|
||||
`lease_end_date` DATE NULL,
|
||||
`standard_lesson_price` INTEGER NOT NULL DEFAULT 0,
|
||||
`signature_image` MEDIUMBLOB NULL,
|
||||
`signature_sha256` VARCHAR(64) NULL,
|
||||
`signature_updated_at` DATETIME(3) NULL,
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `membership_contracts` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`contract_no` VARCHAR(32) NOT NULL,
|
||||
`user_id` VARCHAR(191) NOT NULL,
|
||||
`membership_id` VARCHAR(191) NOT NULL,
|
||||
`order_id` VARCHAR(191) NULL,
|
||||
`source` ENUM('PURCHASE', 'LEGACY') NOT NULL,
|
||||
`status` ENUM('PENDING_SIGN', 'SIGNED', 'VOIDED') NOT NULL DEFAULT 'PENDING_SIGN',
|
||||
`require_before_booking` BOOLEAN NOT NULL DEFAULT false,
|
||||
`template_version` VARCHAR(32) NOT NULL,
|
||||
`terms` JSON NOT NULL,
|
||||
`party_name` VARCHAR(30) NULL,
|
||||
`party_phone` VARCHAR(20) NULL,
|
||||
`document` JSON NULL,
|
||||
`content_sha256` VARCHAR(64) NULL,
|
||||
`signature_image` MEDIUMBLOB NULL,
|
||||
`signature_sha256` VARCHAR(64) NULL,
|
||||
`party_b_signature_image` MEDIUMBLOB NULL,
|
||||
`party_b_name` VARCHAR(100) NULL,
|
||||
`signed_at` DATETIME(3) NULL,
|
||||
`signer_ip` VARCHAR(64) NULL,
|
||||
`signer_user_agent` VARCHAR(300) NULL,
|
||||
`signer_device` VARCHAR(200) NULL,
|
||||
`voided_at` DATETIME(3) NULL,
|
||||
`voided_by` VARCHAR(191) NULL,
|
||||
`void_reason` VARCHAR(200) NULL,
|
||||
`created_by` VARCHAR(191) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `membership_contracts_contract_no_key`(`contract_no`),
|
||||
UNIQUE INDEX `membership_contracts_order_id_key`(`order_id`),
|
||||
INDEX `membership_contracts_user_id_status_idx`(`user_id`, `status`),
|
||||
INDEX `membership_contracts_membership_id_status_idx`(`membership_id`, `status`),
|
||||
INDEX `membership_contracts_status_created_at_idx`(`status`, `created_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `contract_events` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`contract_id` VARCHAR(191) NOT NULL,
|
||||
`action` VARCHAR(20) NOT NULL,
|
||||
`actor_id` VARCHAR(191) NULL,
|
||||
`ip` VARCHAR(64) NULL,
|
||||
`user_agent` VARCHAR(300) NULL,
|
||||
`detail` JSON NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
INDEX `contract_events_contract_id_created_at_idx`(`contract_id`, `created_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `membership_contracts` ADD CONSTRAINT `membership_contracts_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `membership_contracts` ADD CONSTRAINT `membership_contracts_membership_id_fkey` FOREIGN KEY (`membership_id`) REFERENCES `memberships`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `membership_contracts` ADD CONSTRAINT `membership_contracts_order_id_fkey` FOREIGN KEY (`order_id`) REFERENCES `orders`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `contract_events` ADD CONSTRAINT `contract_events_contract_id_fkey` FOREIGN KEY (`contract_id`) REFERENCES `membership_contracts`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
@@ -58,6 +58,17 @@ enum InviteReferralStatus {
|
||||
QUALIFIED
|
||||
}
|
||||
|
||||
enum MembershipContractStatus {
|
||||
PENDING_SIGN
|
||||
SIGNED
|
||||
VOIDED
|
||||
}
|
||||
|
||||
enum MembershipContractSource {
|
||||
PURCHASE
|
||||
LEGACY
|
||||
}
|
||||
|
||||
// ===== Models =====
|
||||
|
||||
model User {
|
||||
@@ -95,6 +106,7 @@ model User {
|
||||
trainingPlans TrainingPlan[]
|
||||
reassessmentTodos ReassessmentTodo[]
|
||||
growthShareCards GrowthShareCard[]
|
||||
membershipContracts MembershipContract[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
@@ -163,6 +175,7 @@ model Membership {
|
||||
bookings Booking[]
|
||||
orders Order[]
|
||||
inviteRewardGrants InviteRewardGrant[]
|
||||
contracts MembershipContract[]
|
||||
|
||||
@@index([userId])
|
||||
@@index([userId, cardTypeId])
|
||||
@@ -273,6 +286,7 @@ model Order {
|
||||
status OrderStatus @default(PENDING)
|
||||
wxTransactionId String? @map("wx_transaction_id")
|
||||
paidAt DateTime? @map("paid_at")
|
||||
contractAcceptedAt DateTime? @map("contract_accepted_at")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@ -280,6 +294,7 @@ model Order {
|
||||
cardType CardType @relation(fields: [cardTypeId], references: [id])
|
||||
membership Membership? @relation(fields: [membershipId], references: [id])
|
||||
inviteReferrals InviteReferral[]
|
||||
contract MembershipContract?
|
||||
|
||||
@@index([userId])
|
||||
@@index([status])
|
||||
@@ -287,6 +302,84 @@ model Order {
|
||||
@@map("orders")
|
||||
}
|
||||
|
||||
// Single-row party B profile; signed contracts keep their own snapshot.
|
||||
model ContractParty {
|
||||
id String @id @default(uuid())
|
||||
businessName String @default("") @map("business_name") @db.VarChar(100)
|
||||
brandName String @default("") @map("brand_name") @db.VarChar(60)
|
||||
creditCode String @default("") @map("credit_code") @db.VarChar(18)
|
||||
operatorName String @default("") @map("operator_name") @db.VarChar(30)
|
||||
phone String @default("") @db.VarChar(20)
|
||||
address String @default("") @db.VarChar(200)
|
||||
premisesType String @default("LEASED") @map("premises_type") @db.VarChar(10)
|
||||
leaseEndDate DateTime? @map("lease_end_date") @db.Date
|
||||
standardLessonPrice Int @default(0) @map("standard_lesson_price")
|
||||
signatureImage Bytes? @map("signature_image") @db.MediumBlob
|
||||
signatureSha256 String? @map("signature_sha256") @db.VarChar(64)
|
||||
signatureUpdatedAt DateTime? @map("signature_updated_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("contract_parties")
|
||||
}
|
||||
|
||||
// terms is frozen at issuance; document, hashes and signatures are frozen at signing.
|
||||
model MembershipContract {
|
||||
id String @id @default(uuid())
|
||||
contractNo String @unique @map("contract_no") @db.VarChar(32)
|
||||
userId String @map("user_id")
|
||||
membershipId String @map("membership_id")
|
||||
orderId String? @unique @map("order_id")
|
||||
source MembershipContractSource
|
||||
status MembershipContractStatus @default(PENDING_SIGN)
|
||||
requireBeforeBooking Boolean @default(false) @map("require_before_booking")
|
||||
templateVersion String @map("template_version") @db.VarChar(32)
|
||||
terms Json
|
||||
partyName String? @map("party_name") @db.VarChar(30)
|
||||
partyPhone String? @map("party_phone") @db.VarChar(20)
|
||||
document Json?
|
||||
contentSha256 String? @map("content_sha256") @db.VarChar(64)
|
||||
signatureImage Bytes? @map("signature_image") @db.MediumBlob
|
||||
signatureSha256 String? @map("signature_sha256") @db.VarChar(64)
|
||||
partyBSignatureImage Bytes? @map("party_b_signature_image") @db.MediumBlob
|
||||
partyBName String? @map("party_b_name") @db.VarChar(100)
|
||||
signedAt DateTime? @map("signed_at")
|
||||
signerIp String? @map("signer_ip") @db.VarChar(64)
|
||||
signerUserAgent String? @map("signer_user_agent") @db.VarChar(300)
|
||||
signerDevice String? @map("signer_device") @db.VarChar(200)
|
||||
voidedAt DateTime? @map("voided_at")
|
||||
voidedBy String? @map("voided_by")
|
||||
voidReason String? @map("void_reason") @db.VarChar(200)
|
||||
createdBy String? @map("created_by")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
membership Membership @relation(fields: [membershipId], references: [id])
|
||||
order Order? @relation(fields: [orderId], references: [id])
|
||||
events ContractEvent[]
|
||||
|
||||
@@index([userId, status])
|
||||
@@index([membershipId, status])
|
||||
@@index([status, createdAt])
|
||||
@@map("membership_contracts")
|
||||
}
|
||||
|
||||
model ContractEvent {
|
||||
id String @id @default(uuid())
|
||||
contractId String @map("contract_id")
|
||||
action String @db.VarChar(20)
|
||||
actorId String? @map("actor_id")
|
||||
ip String? @db.VarChar(64)
|
||||
userAgent String? @map("user_agent") @db.VarChar(300)
|
||||
detail Json?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
contract MembershipContract @relation(fields: [contractId], references: [id])
|
||||
|
||||
@@index([contractId, createdAt])
|
||||
@@map("contract_events")
|
||||
}
|
||||
|
||||
model InviteReferral {
|
||||
id String @id @default(uuid())
|
||||
inviterId String @map("inviter_id")
|
||||
|
||||
@@ -14,6 +14,7 @@ import { PaymentModule } from './payment/payment.module'
|
||||
import { AdminModule } from './admin/admin.module'
|
||||
import { InviteModule } from './invite/invite.module'
|
||||
import { BodyPortraitModule } from './body-portrait/body-portrait.module'
|
||||
import { ContractModule } from './contract/contract.module'
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -34,6 +35,7 @@ import { BodyPortraitModule } from './body-portrait/body-portrait.module'
|
||||
AdminModule,
|
||||
InviteModule,
|
||||
BodyPortraitModule,
|
||||
ContractModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
})
|
||||
|
||||
@@ -161,6 +161,9 @@ function buildTxMock(overrides: Record<string, unknown> = {}) {
|
||||
user: {
|
||||
findUnique: jest.fn(),
|
||||
},
|
||||
membershipContract: {
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
bookingStatusHistory: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
@@ -492,6 +495,21 @@ describe('BookingService', () => {
|
||||
describe('createBooking', () => {
|
||||
const dto = { timeSlotId: MOCK_SLOT_ID, membershipId: MOCK_MEMBERSHIP_ID }
|
||||
|
||||
it('blocks booking until the purchase contract is signed', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||
tx.booking.findUnique.mockResolvedValue(null)
|
||||
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
|
||||
tx.membershipContract.findFirst.mockResolvedValue({ id: 'contract-1' })
|
||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||
|
||||
await expect(service.createBooking(MOCK_USER_ID, dto)).rejects.toThrow('请先签署会员卡合同')
|
||||
expect(tx.membershipContract.findFirst).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { membershipId: mockActiveMembership.id, status: 'PENDING_SIGN', requireBeforeBooking: true },
|
||||
}))
|
||||
expect(tx.booking.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('creates booking in pending confirmation status', async () => {
|
||||
const tx = buildTxMock()
|
||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||
|
||||
@@ -10,6 +10,8 @@ import { Booking, Membership, TimeSlot, BookingStatusHistory } from '@prisma/cli
|
||||
import {
|
||||
BookingStatus,
|
||||
CardTypeCategory,
|
||||
CONTRACT_UNSIGNED_MESSAGE,
|
||||
MembershipContractStatus,
|
||||
MembershipStatus,
|
||||
TimeSlotStatus,
|
||||
type TeachingScheduleSlot,
|
||||
@@ -131,6 +133,11 @@ export class BookingService {
|
||||
throw new BadRequestException('Membership has expired')
|
||||
}
|
||||
if (membership.cardType.type === CardTypeCategory.DURATION && !canUseMembershipForSlot(membership, timeSlot)) throw new BadRequestException('课程不在该月卡周期内,或该周期已无可用课时')
|
||||
const unsignedContract = await tx.membershipContract.findFirst({
|
||||
where: { membershipId: membership.id, status: MembershipContractStatus.PENDING_SIGN, requireBeforeBooking: true },
|
||||
select: { id: true },
|
||||
})
|
||||
if (unsignedContract) throw new ForbiddenException(CONTRACT_UNSIGNED_MESSAGE)
|
||||
|
||||
let originAssessmentId: string | undefined
|
||||
if (dto.originAssessmentId) {
|
||||
|
||||
135
packages/server/src/contract/__tests__/contract-template.spec.ts
Normal file
135
packages/server/src/contract/__tests__/contract-template.spec.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import { MembershipContractSource } from '@mp-pilates/shared'
|
||||
import {
|
||||
buildContractDocument,
|
||||
formatChinaDateTime,
|
||||
formatYuan,
|
||||
hashContractDocument,
|
||||
toChineseAmount,
|
||||
type ContractRenderInput,
|
||||
type ContractTerms,
|
||||
} from '../contract-template'
|
||||
|
||||
const baseTerms: ContractTerms = {
|
||||
kind: 'MULTI_TIMES',
|
||||
cardTypeId: 'card-10',
|
||||
cardName: '私教10次卡',
|
||||
lessonCount: 10,
|
||||
durationDays: 1095,
|
||||
listPrice: 388000,
|
||||
amount: 388000,
|
||||
discountNote: null,
|
||||
orderNos: ['1730000000000abc'],
|
||||
paidAt: '2026-10-28T03:37:00.000Z',
|
||||
acceptedAt: '2026-10-28T03:35:00.000Z',
|
||||
startDate: '2026-10-28T03:37:00.000Z',
|
||||
expireDate: '2029-10-27T03:37:00.000Z',
|
||||
scheduled: false,
|
||||
renewal: false,
|
||||
availableTimesAfter: null,
|
||||
legacyRemainingTimes: null,
|
||||
legacyAsOf: null,
|
||||
}
|
||||
|
||||
function render(overrides: Partial<ContractRenderInput> = {}, terms: Partial<ContractTerms> = {}) {
|
||||
return buildContractDocument({
|
||||
contractNo: 'MP20261028000001',
|
||||
source: MembershipContractSource.PURCHASE,
|
||||
terms: { ...baseTerms, ...terms },
|
||||
partyA: { realName: '张三', phone: '13800000000', openid: 'o2Gv_0FiEg3C7dGwK0ib7-75wlE4' },
|
||||
partyB: {
|
||||
businessName: '深圳市宝安区某某普拉提工作室',
|
||||
brandName: 'MP Pilates',
|
||||
creditCode: '92440300MA5XXXXX1X',
|
||||
operatorName: '李四',
|
||||
phone: '19500000000',
|
||||
address: '深圳市宝安区某路 1 号',
|
||||
premisesType: 'LEASED',
|
||||
leaseEndDate: '2028-06-30T00:00:00.000Z',
|
||||
standardLessonPrice: 45000,
|
||||
},
|
||||
cancelHoursLimit: 2,
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
function allText(document: ReturnType<typeof render>) {
|
||||
return [
|
||||
...document.cardRows.map(row => `${row.label}:${row.value}`),
|
||||
...document.sections.flatMap(section => section.clauses.map(clause => clause.text)),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
describe('contract formatting', () => {
|
||||
it('formats amounts in yuan and Chinese capitals', () => {
|
||||
expect(formatYuan(388000)).toBe('3,880.00')
|
||||
expect(toChineseAmount(388000)).toBe('人民币叁仟捌佰捌拾元整')
|
||||
expect(toChineseAmount(750000)).toBe('人民币柒仟伍佰元整')
|
||||
expect(toChineseAmount(1000100)).toBe('人民币壹万零壹元整')
|
||||
expect(toChineseAmount(10000000)).toBe('人民币壹拾万元整')
|
||||
expect(toChineseAmount(12345)).toBe('人民币壹佰贰拾叁元肆角伍分')
|
||||
})
|
||||
|
||||
it('formats timestamps in China time', () => {
|
||||
expect(formatChinaDateTime('2026-10-28T03:37:00.000Z')).toBe('2026年10月28日 11:37')
|
||||
expect(formatChinaDateTime('2026-10-28T17:10:00.000Z')).toBe('2026年10月29日 01:10')
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildContractDocument', () => {
|
||||
it('renders a multi-times contract with standard-price refunds and fixed clause numbers', () => {
|
||||
const document = render()
|
||||
const refund = document.sections.find(section => section.title.includes('退费'))!
|
||||
expect(refund.clauses[0].text).toMatch(/^1\. 七日无理由退款/)
|
||||
expect(refund.clauses[1].text).toContain('单节标准课价(¥450.00)')
|
||||
expect(refund.clauses[2].text).toMatch(/^3\. 乙方原因退费/)
|
||||
expect(refund.clauses.slice(0, 3).every(clause => clause.emphasis)).toBe(true)
|
||||
const text = allText(document)
|
||||
expect(text).toContain('卡种类别:多次卡')
|
||||
expect(text).toContain('人民币叁仟捌佰捌拾元整')
|
||||
expect(text).toContain('取消或未到场上课的,该节课时视为已使用')
|
||||
expect(text).toContain('课程开始前 2 小时以前取消')
|
||||
expect(text).not.toContain('最终解释权')
|
||||
expect(document.partyB.find(row => row.label === '场所性质')?.value).toBe('租赁(租期至 2028年06月30日)')
|
||||
expect(document.partyA.find(row => row.label === '微信账号标识')?.value).toBe('o2Gv****wlE4')
|
||||
})
|
||||
|
||||
it('explains renewal merges for times cards', () => {
|
||||
const text = allText(render({}, { renewal: true, availableTimesAfter: 14 }))
|
||||
expect(text).toContain('续购后合计可用 14 节')
|
||||
expect(text).toContain('优先计入较早购买的课时')
|
||||
})
|
||||
|
||||
it('renders monthly cards per period and prorates unlimited monthly refunds by days', () => {
|
||||
const limited = allText(render({}, { kind: 'MONTHLY', cardName: '月卡', lessonCount: 10, durationDays: 30, scheduled: true }))
|
||||
expect(limited).toContain('卡种类别:月卡')
|
||||
expect(limited).toContain('每期课时:10 节')
|
||||
expect(limited).toContain('未使用的课时不结转至下一期')
|
||||
expect(limited).toContain('自上一期届满时起生效')
|
||||
expect(limited).toContain('月卡按期计算')
|
||||
|
||||
const unlimited = allText(render({}, { kind: 'MONTHLY', lessonCount: null, durationDays: 30 }))
|
||||
expect(unlimited).toContain('实付金额 × 剩余有效天数 ÷ 总有效天数')
|
||||
expect(unlimited).not.toContain('乙方确认预约时扣除 1 节课时')
|
||||
})
|
||||
|
||||
it('marks missing party information as pending and renders legacy terms', () => {
|
||||
const document = render(
|
||||
{
|
||||
source: MembershipContractSource.LEGACY,
|
||||
partyA: { realName: null, phone: null, openid: 'openid-123456789' },
|
||||
},
|
||||
{ legacyRemainingTimes: 6, legacyAsOf: '2026-10-28T03:37:00.000Z', orderNos: [] },
|
||||
)
|
||||
expect(document.title).toContain('存量会员补签')
|
||||
expect(document.partyA[0].value).toBe('(待填写)')
|
||||
const text = allText(document)
|
||||
expect(text).toContain('剩余课时:6 节(截至 2026年10月28日)')
|
||||
expect(text).toContain('支付方式:以乙方收款记录为准')
|
||||
expect(text).toContain('双方确认此前的付款、预约、上课及课时扣减记录真实有效')
|
||||
})
|
||||
|
||||
it('hashes deterministically and detects content changes', () => {
|
||||
expect(hashContractDocument(render())).toBe(hashContractDocument(render()))
|
||||
expect(hashContractDocument(render())).not.toBe(hashContractDocument(render({ cancelHoursLimit: 4 })))
|
||||
})
|
||||
})
|
||||
315
packages/server/src/contract/__tests__/contract.service.spec.ts
Normal file
315
packages/server/src/contract/__tests__/contract.service.spec.ts
Normal file
@@ -0,0 +1,315 @@
|
||||
import { BadRequestException, ConflictException, NotFoundException } from '@nestjs/common'
|
||||
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'
|
||||
|
||||
const PNG = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), Buffer.alloc(800, 1)])
|
||||
const PNG_DATA_URI = `data:image/png;base64,${PNG.toString('base64')}`
|
||||
|
||||
const party = {
|
||||
id: 'party-1',
|
||||
businessName: '某某普拉提工作室',
|
||||
brandName: '',
|
||||
creditCode: '92440300MA5XXXXX1X',
|
||||
operatorName: '李四',
|
||||
phone: '19500000000',
|
||||
address: '深圳市宝安区某路 1 号',
|
||||
premisesType: 'LEASED',
|
||||
leaseEndDate: null,
|
||||
standardLessonPrice: 45000,
|
||||
signatureImage: PNG,
|
||||
signatureSha256: 'party-sha',
|
||||
signatureUpdatedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
const timesCard = {
|
||||
id: 'card-10',
|
||||
name: '私教10次卡',
|
||||
type: 'TIMES',
|
||||
totalTimes: 10,
|
||||
durationDays: 1095,
|
||||
price: new Decimal(388000),
|
||||
originalPrice: null,
|
||||
description: null,
|
||||
coverUrl: null,
|
||||
isActive: true,
|
||||
sortOrder: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
function buildPrisma() {
|
||||
const prisma = {
|
||||
$queryRaw: jest.fn(),
|
||||
$transaction: jest.fn(),
|
||||
membershipContract: {
|
||||
findUnique: jest.fn(),
|
||||
findFirst: jest.fn(),
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'contract-new', createdAt: new Date(), signedAt: null, voidReason: null, status: 'PENDING_SIGN', ...data })),
|
||||
update: jest.fn(),
|
||||
updateMany: jest.fn(),
|
||||
count: jest.fn(),
|
||||
},
|
||||
contractEvent: { create: jest.fn() },
|
||||
contractParty: { findFirst: jest.fn().mockResolvedValue(party), create: jest.fn() },
|
||||
membership: { findMany: jest.fn(), findUnique: jest.fn() },
|
||||
order: { findMany: jest.fn() },
|
||||
cardType: { findFirst: jest.fn(), findUnique: jest.fn() },
|
||||
user: { findUnique: jest.fn() },
|
||||
inviteReferral: { findUnique: jest.fn() },
|
||||
}
|
||||
prisma.$transaction.mockImplementation((fn: (tx: typeof prisma) => unknown) => fn(prisma))
|
||||
return prisma
|
||||
}
|
||||
|
||||
function buildService(prisma = buildPrisma()) {
|
||||
const studio = { getInfo: jest.fn().mockResolvedValue({ cancelHoursLimit: 2 }) }
|
||||
const pdf = { render: jest.fn().mockResolvedValue(Buffer.from('%PDF')) }
|
||||
return { service: new ContractService(prisma as never, studio as never, pdf as never), prisma, pdf }
|
||||
}
|
||||
|
||||
const order = {
|
||||
id: 'order-1',
|
||||
userId: 'user-1',
|
||||
cardTypeId: 'card-10',
|
||||
membershipId: null,
|
||||
orderNo: '1730000000000abc',
|
||||
inviteInviterId: null,
|
||||
purchasedCategory: 'TIMES',
|
||||
amount: new Decimal(388000),
|
||||
status: 'PENDING',
|
||||
wxTransactionId: null,
|
||||
paidAt: null,
|
||||
contractAcceptedAt: new Date('2026-10-28T03:35:00.000Z'),
|
||||
createdAt: new Date('2026-10-28T03:35:00.000Z'),
|
||||
updatedAt: new Date(),
|
||||
}
|
||||
|
||||
describe('ContractService.issueForPaidOrder', () => {
|
||||
const paidAt = new Date('2026-10-28T03:37:00.000Z')
|
||||
|
||||
it('does not issue contracts for single-lesson or trial cards', async () => {
|
||||
const { service, prisma } = buildService()
|
||||
const result = await service.issueForPaidOrder(prisma as never, {
|
||||
order: order as never,
|
||||
cardType: { ...timesCard, totalTimes: 1 } as never,
|
||||
membership: {} as never,
|
||||
paidAt,
|
||||
})
|
||||
expect(result).toBeNull()
|
||||
expect(prisma.membershipContract.create).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('issues a booking-blocking contract with frozen purchase terms', async () => {
|
||||
const { service, prisma } = buildService()
|
||||
prisma.membershipContract.findUnique.mockResolvedValue(null)
|
||||
const membership = {
|
||||
id: 'membership-1',
|
||||
createdAt: new Date('2026-10-28T03:37:00.100Z'),
|
||||
startDate: paidAt,
|
||||
expireDate: new Date('2029-10-27T03:37:00.000Z'),
|
||||
remainingTimes: 10,
|
||||
}
|
||||
await service.issueForPaidOrder(prisma as never, { order: order as never, cardType: timesCard as never, membership: membership as never, paidAt })
|
||||
const data = prisma.membershipContract.create.mock.calls[0][0].data
|
||||
expect(data).toMatchObject({
|
||||
userId: 'user-1',
|
||||
membershipId: 'membership-1',
|
||||
orderId: 'order-1',
|
||||
source: MembershipContractSource.PURCHASE,
|
||||
requireBeforeBooking: true,
|
||||
})
|
||||
expect(data.contractNo).toMatch(/^MP\d{14}$/)
|
||||
expect(data.terms).toMatchObject({ kind: 'MULTI_TIMES', lessonCount: 10, amount: 388000, renewal: false, acceptedAt: '2026-10-28T03:35:00.000Z' })
|
||||
expect(prisma.contractEvent.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: 'CREATED' }) }))
|
||||
})
|
||||
|
||||
it('records renewals that merge into an earlier times card', async () => {
|
||||
const { service, prisma } = buildService()
|
||||
prisma.membershipContract.findUnique.mockResolvedValue(null)
|
||||
const membership = {
|
||||
id: 'membership-old',
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
startDate: new Date('2026-01-01T00:00:00.000Z'),
|
||||
expireDate: new Date('2029-10-27T03:37:00.000Z'),
|
||||
remainingTimes: 14,
|
||||
}
|
||||
await service.issueForPaidOrder(prisma as never, { order: order as never, cardType: timesCard as never, membership: membership as never, paidAt })
|
||||
expect(prisma.membershipContract.create.mock.calls[0][0].data.terms).toMatchObject({
|
||||
renewal: true,
|
||||
availableTimesAfter: 14,
|
||||
startDate: paidAt.toISOString(),
|
||||
})
|
||||
})
|
||||
|
||||
it('treats a reset expired times card as a fresh purchase starting at payment', async () => {
|
||||
const { service, prisma } = buildService()
|
||||
prisma.membershipContract.findUnique.mockResolvedValue(null)
|
||||
const membership = {
|
||||
id: 'membership-old',
|
||||
createdAt: new Date('2025-01-01T00:00:00.000Z'),
|
||||
startDate: new Date('2025-01-01T00:00:00.000Z'),
|
||||
expireDate: new Date('2029-10-27T03:37:00.000Z'),
|
||||
remainingTimes: 10,
|
||||
}
|
||||
await service.issueForPaidOrder(prisma as never, { order: order as never, cardType: timesCard as never, membership: membership as never, paidAt })
|
||||
expect(prisma.membershipContract.create.mock.calls[0][0].data.terms).toMatchObject({
|
||||
renewal: false,
|
||||
availableTimesAfter: null,
|
||||
startDate: paidAt.toISOString(),
|
||||
})
|
||||
})
|
||||
|
||||
it('is idempotent per order', async () => {
|
||||
const { service, prisma } = buildService()
|
||||
prisma.membershipContract.findUnique.mockResolvedValue({ id: 'existing' })
|
||||
const result = await service.issueForPaidOrder(prisma as never, { order: order as never, cardType: timesCard as never, membership: {} as never, paidAt })
|
||||
expect(result).toEqual({ id: 'existing' })
|
||||
expect(prisma.membershipContract.create).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ContractService.syncLegacyContracts', () => {
|
||||
const future = new Date(Date.now() + 30 * 86400000)
|
||||
const baseMembership = {
|
||||
userId: 'user-1',
|
||||
cardTypeId: 'card-10',
|
||||
cardType: timesCard,
|
||||
user: { id: 'user-1', nickname: '学员', phone: null },
|
||||
status: 'ACTIVE',
|
||||
remainingTimes: 6,
|
||||
totalTimes: 10,
|
||||
startDate: new Date('2026-01-01T00:00:00.000Z'),
|
||||
expireDate: future,
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
}
|
||||
|
||||
it('creates legacy contracts from uncovered paid orders and lists cards without orders', async () => {
|
||||
const { service, prisma } = buildService()
|
||||
prisma.membership.findMany.mockResolvedValue([
|
||||
{ ...baseMembership, id: 'with-orders', orders: [{ id: 'o1', orderNo: 'NO1', amount: new Decimal(388000), paidAt: new Date('2026-01-01') }], contracts: [] },
|
||||
{ ...baseMembership, id: 'no-orders', orders: [], contracts: [] },
|
||||
{ ...baseMembership, id: 'covered', orders: [{ id: 'o2', amount: new Decimal(1) }], contracts: [{ orderId: 'o2', source: 'PURCHASE' }] },
|
||||
{ ...baseMembership, id: 'single', cardType: { ...timesCard, totalTimes: 1 }, orders: [], contracts: [] },
|
||||
])
|
||||
prisma.membership.findUnique.mockResolvedValue({ ...baseMembership, id: 'with-orders' })
|
||||
prisma.membershipContract.findFirst.mockResolvedValue(null)
|
||||
|
||||
const result = await service.syncLegacyContracts('user-1')
|
||||
|
||||
expect(result.created).toBe(1)
|
||||
expect(result.needsAmount.map(item => item.membershipId)).toEqual(['no-orders'])
|
||||
const data = prisma.membershipContract.create.mock.calls[0][0].data
|
||||
expect(data).toMatchObject({ membershipId: 'with-orders', source: MembershipContractSource.LEGACY, requireBeforeBooking: false })
|
||||
expect(data.terms).toMatchObject({ amount: 388000, orderNos: ['NO1'], legacyRemainingTimes: 6 })
|
||||
})
|
||||
|
||||
it('does not duplicate an existing legacy contract under concurrent syncs', async () => {
|
||||
const { service, prisma } = buildService()
|
||||
prisma.membership.findMany.mockResolvedValue([
|
||||
{ ...baseMembership, id: 'with-orders', orders: [{ id: 'o1', orderNo: 'NO1', amount: new Decimal(388000), paidAt: null }], contracts: [] },
|
||||
])
|
||||
prisma.membership.findUnique.mockResolvedValue({ ...baseMembership, id: 'with-orders' })
|
||||
prisma.membershipContract.findFirst.mockResolvedValue({ id: 'raced' })
|
||||
const result = await service.syncLegacyContracts('user-1')
|
||||
expect(result.created).toBe(0)
|
||||
expect(prisma.membershipContract.create).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ContractService.sign', () => {
|
||||
const terms = {
|
||||
kind: 'MULTI_TIMES', cardTypeId: 'card-10', cardName: '私教10次卡', lessonCount: 10, durationDays: 1095,
|
||||
listPrice: 388000, amount: 388000, discountNote: null, orderNos: ['NO1'], paidAt: '2026-10-28T03:37:00.000Z',
|
||||
acceptedAt: null, startDate: '2026-10-28T03:37:00.000Z', expireDate: '2029-10-27T03:37:00.000Z',
|
||||
scheduled: false, renewal: false, availableTimesAfter: null, legacyRemainingTimes: null, legacyAsOf: null,
|
||||
}
|
||||
const contract = {
|
||||
id: 'contract-1',
|
||||
contractNo: 'MP20261028000001',
|
||||
userId: 'user-1',
|
||||
membershipId: 'membership-1',
|
||||
orderId: 'order-1',
|
||||
source: 'PURCHASE',
|
||||
status: 'PENDING_SIGN',
|
||||
requireBeforeBooking: true,
|
||||
terms,
|
||||
partyName: '张三',
|
||||
partyPhone: '13800000000',
|
||||
createdAt: new Date(),
|
||||
signedAt: null,
|
||||
voidReason: null,
|
||||
user: { id: 'user-1', nickname: '学员', phone: '13800000000', openid: 'openid-123456789' },
|
||||
}
|
||||
const expectedHash = hashContractDocument(buildContractDocument({
|
||||
contractNo: contract.contractNo,
|
||||
source: MembershipContractSource.PURCHASE,
|
||||
terms: terms as never,
|
||||
partyA: { realName: '张三', phone: '13800000000', openid: 'openid-123456789' },
|
||||
partyB: { ...party, premisesType: 'LEASED', leaseEndDate: null },
|
||||
cancelHoursLimit: 2,
|
||||
}))
|
||||
const dto = { contentSha256: expectedHash, signatureImage: PNG_DATA_URI, agreedTerms: true, confirmedHealth: true }
|
||||
const meta = { ip: '1.2.3.4', userAgent: 'wechat' }
|
||||
|
||||
it('hides other members\' contracts', async () => {
|
||||
const { service, prisma } = buildService()
|
||||
prisma.membershipContract.findUnique.mockResolvedValue({ ...contract, userId: 'someone-else' })
|
||||
await expect(service.sign('user-1', 'contract-1', dto, meta)).rejects.toBeInstanceOf(NotFoundException)
|
||||
})
|
||||
|
||||
it('rejects signing when the reviewed content has changed', async () => {
|
||||
const { service, prisma } = buildService()
|
||||
prisma.membershipContract.findUnique.mockResolvedValue(contract)
|
||||
await expect(service.sign('user-1', 'contract-1', { ...dto, contentSha256: 'a'.repeat(64) }, meta)).rejects.toBeInstanceOf(ConflictException)
|
||||
})
|
||||
|
||||
it('rejects signing until party B profile is complete', async () => {
|
||||
const { service, prisma } = buildService()
|
||||
prisma.contractParty.findFirst.mockResolvedValue({ ...party, signatureImage: null })
|
||||
prisma.membershipContract.findUnique.mockResolvedValue(contract)
|
||||
await expect(service.sign('user-1', 'contract-1', dto, meta)).rejects.toBeInstanceOf(BadRequestException)
|
||||
})
|
||||
|
||||
it('freezes the document, hashes and both signatures atomically', async () => {
|
||||
const { service, prisma } = buildService()
|
||||
prisma.membershipContract.findUnique.mockResolvedValue(contract)
|
||||
prisma.membershipContract.updateMany.mockResolvedValue({ count: 1 })
|
||||
await service.sign('user-1', 'contract-1', dto, meta)
|
||||
const update = prisma.membershipContract.updateMany.mock.calls[0][0]
|
||||
expect(update.where).toEqual({ id: 'contract-1', status: MembershipContractStatus.PENDING_SIGN })
|
||||
expect(update.data).toMatchObject({
|
||||
status: MembershipContractStatus.SIGNED,
|
||||
contentSha256: expectedHash,
|
||||
signerIp: '1.2.3.4',
|
||||
partyBSignatureImage: PNG,
|
||||
})
|
||||
expect(update.data.signatureSha256).toMatch(/^[a-f0-9]{64}$/)
|
||||
expect(prisma.contractEvent.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: 'SIGNED' }) }))
|
||||
})
|
||||
|
||||
it('reports a concurrent signature as a conflict', async () => {
|
||||
const { service, prisma } = buildService()
|
||||
prisma.membershipContract.findUnique.mockResolvedValue(contract)
|
||||
prisma.membershipContract.updateMany.mockResolvedValue({ count: 0 })
|
||||
await expect(service.sign('user-1', 'contract-1', dto, meta)).rejects.toBeInstanceOf(ConflictException)
|
||||
})
|
||||
|
||||
it('only exports signed contracts', async () => {
|
||||
const { service, prisma, pdf } = buildService()
|
||||
prisma.membershipContract.findUnique.mockResolvedValue(contract)
|
||||
await expect(service.exportPdf('contract-1', { id: 'user-1', isAdmin: false }, meta)).rejects.toBeInstanceOf(BadRequestException)
|
||||
expect(pdf.render).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('decodeSignaturePng', () => {
|
||||
it('accepts PNG data URIs and rejects other payloads', () => {
|
||||
expect(decodeSignaturePng(PNG_DATA_URI).equals(PNG)).toBe(true)
|
||||
expect(() => decodeSignaturePng(Buffer.alloc(900).toString('base64'))).toThrow(BadRequestException)
|
||||
expect(() => decodeSignaturePng(PNG.subarray(0, 100).toString('base64'))).toThrow(BadRequestException)
|
||||
})
|
||||
})
|
||||
155
packages/server/src/contract/contract-pdf.service.ts
Normal file
155
packages/server/src/contract/contract-pdf.service.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common'
|
||||
import { ConfigService } from '@nestjs/config'
|
||||
import { existsSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import PDFDocument from 'pdfkit'
|
||||
import type { ContractDocument, ContractRow } from '@mp-pilates/shared'
|
||||
import { formatChinaDateTime } from './contract-template'
|
||||
|
||||
export interface ContractPdfInput {
|
||||
document: ContractDocument
|
||||
contentSha256: string
|
||||
signerName: string
|
||||
signedAt: string
|
||||
memberSignature: Buffer
|
||||
partyBSignature: Buffer | null
|
||||
partyBName: string
|
||||
}
|
||||
|
||||
const PAGE_MARGIN = 56
|
||||
const TEXT_COLOR = '#2f2a26'
|
||||
const MUTED_COLOR = '#7a6f66'
|
||||
const LINE_COLOR = '#d8cfc6'
|
||||
|
||||
@Injectable()
|
||||
export class ContractPdfService {
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
private resolveFont(): string {
|
||||
const candidates = [
|
||||
this.config.get<string>('CONTRACT_PDF_FONT_PATH')?.trim(),
|
||||
join(__dirname, '..', '..', 'assets', 'fonts', 'NotoSansSC-Regular.otf'),
|
||||
].filter((item): item is string => !!item)
|
||||
const found = candidates.find(path => existsSync(path))
|
||||
if (!found) throw new ServiceUnavailableException('合同字体文件缺失,暂时无法导出')
|
||||
return found
|
||||
}
|
||||
|
||||
async render(input: ContractPdfInput): Promise<Buffer> {
|
||||
const doc = new PDFDocument({
|
||||
size: 'A4',
|
||||
margins: { top: PAGE_MARGIN, bottom: PAGE_MARGIN + 18, left: PAGE_MARGIN, right: PAGE_MARGIN },
|
||||
bufferPages: true,
|
||||
info: {
|
||||
Title: `${input.document.title} ${input.document.contractNo}`,
|
||||
Subject: `SHA-256 ${input.contentSha256}`,
|
||||
CreationDate: new Date(input.signedAt),
|
||||
},
|
||||
})
|
||||
const chunks: Buffer[] = []
|
||||
doc.on('data', (chunk: Buffer) => chunks.push(chunk))
|
||||
const finished = new Promise<Buffer>((resolve, reject) => {
|
||||
doc.on('end', () => resolve(Buffer.concat(chunks)))
|
||||
doc.on('error', reject)
|
||||
})
|
||||
|
||||
doc.registerFont('cn', this.resolveFont())
|
||||
doc.font('cn').fillColor(TEXT_COLOR)
|
||||
const width = doc.page.width - PAGE_MARGIN * 2
|
||||
const { document } = input
|
||||
|
||||
doc.fontSize(18).text(document.title, { align: 'center' })
|
||||
doc.moveDown(0.3)
|
||||
doc.fontSize(9).fillColor(MUTED_COLOR).text(`合同编号:${document.contractNo} 模板版本:${document.templateVersion}`, { align: 'center' })
|
||||
doc.moveDown(0.8)
|
||||
doc.fontSize(9).fillColor(MUTED_COLOR).text(document.notice, { lineGap: 2 })
|
||||
doc.moveDown(0.8)
|
||||
|
||||
this.heading(doc, '甲方(会员)')
|
||||
this.rows(doc, document.partyA, width)
|
||||
this.heading(doc, '乙方(经营者)')
|
||||
this.rows(doc, document.partyB, width)
|
||||
this.heading(doc, '会员卡信息')
|
||||
this.rows(doc, document.cardRows, width)
|
||||
|
||||
for (const section of document.sections) {
|
||||
this.heading(doc, section.title)
|
||||
for (const clause of section.clauses) {
|
||||
doc.fontSize(10).fillColor(TEXT_COLOR)
|
||||
if (clause.emphasis) {
|
||||
doc.lineWidth(0.22).strokeColor(TEXT_COLOR).text(clause.text, { lineGap: 3, fill: true, stroke: true })
|
||||
} else {
|
||||
doc.text(clause.text, { lineGap: 3 })
|
||||
}
|
||||
doc.moveDown(0.25)
|
||||
}
|
||||
}
|
||||
|
||||
doc.moveDown(0.6)
|
||||
doc.fontSize(10).lineWidth(0.22).strokeColor(TEXT_COLOR).text(document.commitment, { lineGap: 3, fill: true, stroke: true })
|
||||
this.signatures(doc, input, width)
|
||||
|
||||
const range = doc.bufferedPageRange()
|
||||
for (let index = 0; index < range.count; index++) {
|
||||
doc.switchToPage(range.start + index)
|
||||
const bottom = doc.page.height - PAGE_MARGIN + 4
|
||||
doc.page.margins.bottom = 0
|
||||
doc.fontSize(7.5).fillColor(MUTED_COLOR).text(
|
||||
`合同编号 ${document.contractNo} | 第 ${index + 1} 页 / 共 ${range.count} 页 | 内容摘要 SHA-256:${input.contentSha256}`,
|
||||
PAGE_MARGIN,
|
||||
bottom,
|
||||
{ width, align: 'center', lineBreak: false },
|
||||
)
|
||||
}
|
||||
doc.end()
|
||||
return finished
|
||||
}
|
||||
|
||||
private heading(doc: PDFKit.PDFDocument, title: string) {
|
||||
doc.moveDown(0.6)
|
||||
doc.fontSize(12).fillColor(TEXT_COLOR).lineWidth(0.4).strokeColor(TEXT_COLOR).text(title, { fill: true, stroke: true })
|
||||
doc.moveDown(0.35)
|
||||
}
|
||||
|
||||
private rows(doc: PDFKit.PDFDocument, rows: readonly ContractRow[], width: number) {
|
||||
const labelWidth = 96
|
||||
const gap = 10
|
||||
for (const row of rows) {
|
||||
doc.fontSize(10)
|
||||
const valueHeight = doc.heightOfString(row.value, { width: width - labelWidth - gap, lineGap: 2 })
|
||||
if (doc.y + valueHeight > doc.page.height - doc.page.margins.bottom) doc.addPage()
|
||||
const top = doc.y
|
||||
doc.fillColor(MUTED_COLOR).text(row.label, PAGE_MARGIN, top, { width: labelWidth })
|
||||
doc.fillColor(TEXT_COLOR).text(row.value, PAGE_MARGIN + labelWidth + gap, top, { width: width - labelWidth - gap, lineGap: 2 })
|
||||
doc.x = PAGE_MARGIN
|
||||
doc.y = Math.max(doc.y, top + valueHeight) + 4
|
||||
doc.moveTo(PAGE_MARGIN, doc.y - 2).lineTo(PAGE_MARGIN + width, doc.y - 2).lineWidth(0.3).strokeColor(LINE_COLOR).stroke()
|
||||
}
|
||||
}
|
||||
|
||||
private signatures(doc: PDFKit.PDFDocument, input: ContractPdfInput, width: number) {
|
||||
const blockHeight = 150
|
||||
if (doc.y + blockHeight > doc.page.height - doc.page.margins.bottom) doc.addPage()
|
||||
doc.moveDown(1.2)
|
||||
const top = doc.y
|
||||
const column = width / 2
|
||||
const signedAt = formatChinaDateTime(input.signedAt)
|
||||
|
||||
doc.fontSize(10).fillColor(TEXT_COLOR)
|
||||
doc.text('甲方(签名):', PAGE_MARGIN, top)
|
||||
doc.image(input.memberSignature, PAGE_MARGIN, top + 18, { fit: [column - 24, 70] })
|
||||
doc.fontSize(9).fillColor(MUTED_COLOR)
|
||||
doc.text(`姓名:${input.signerName}`, PAGE_MARGIN, top + 94)
|
||||
doc.text(`签署时间:${signedAt}`, PAGE_MARGIN, top + 110)
|
||||
|
||||
const right = PAGE_MARGIN + column
|
||||
doc.fontSize(10).fillColor(TEXT_COLOR)
|
||||
doc.text('乙方(签章):', right, top)
|
||||
if (input.partyBSignature) doc.image(input.partyBSignature, right, top + 18, { fit: [column - 24, 70] })
|
||||
doc.fontSize(9).fillColor(MUTED_COLOR)
|
||||
doc.text(input.partyBName, right, top + 94, { width: column })
|
||||
doc.text(`签署时间:${signedAt}`, right, top + 110)
|
||||
doc.x = PAGE_MARGIN
|
||||
doc.y = top + blockHeight
|
||||
}
|
||||
}
|
||||
405
packages/server/src/contract/contract-template.ts
Normal file
405
packages/server/src/contract/contract-template.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
import { createHash } from 'crypto'
|
||||
import {
|
||||
MembershipContractSource,
|
||||
type ContractClause,
|
||||
type ContractDocument,
|
||||
type ContractKind,
|
||||
type ContractPremisesType,
|
||||
type ContractRow,
|
||||
type ContractSection,
|
||||
} from '@mp-pilates/shared'
|
||||
|
||||
export const CONTRACT_TEMPLATE_VERSION = '2026-10-v1'
|
||||
|
||||
const CHINA_OFFSET_MS = 8 * 3600 * 1000
|
||||
const PENDING_TEXT = '(待填写)'
|
||||
|
||||
/** 签发时冻结的交易事实,后续续费或改卡不影响已签发合同。 */
|
||||
export interface ContractTerms {
|
||||
kind: ContractKind
|
||||
cardTypeId: string
|
||||
cardName: string
|
||||
/** 本合同对应的课时数;null 为不限次。 */
|
||||
lessonCount: number | null
|
||||
durationDays: number
|
||||
listPrice: number
|
||||
amount: number
|
||||
discountNote: string | null
|
||||
orderNos: string[]
|
||||
paidAt: string | null
|
||||
acceptedAt: string | null
|
||||
startDate: string | null
|
||||
expireDate: string | null
|
||||
scheduled: boolean
|
||||
renewal: boolean
|
||||
availableTimesAfter: number | null
|
||||
legacyRemainingTimes: number | null
|
||||
legacyAsOf: string | null
|
||||
}
|
||||
|
||||
export interface ContractPartyAInput {
|
||||
realName: string | null
|
||||
phone: string | null
|
||||
openid: string
|
||||
}
|
||||
|
||||
export interface ContractPartyBInput {
|
||||
businessName: string
|
||||
brandName: string
|
||||
creditCode: string
|
||||
operatorName: string
|
||||
phone: string
|
||||
address: string
|
||||
premisesType: ContractPremisesType
|
||||
leaseEndDate: string | null
|
||||
standardLessonPrice: number
|
||||
}
|
||||
|
||||
export interface ContractRenderInput {
|
||||
contractNo: string
|
||||
source: MembershipContractSource
|
||||
terms: ContractTerms
|
||||
partyA: ContractPartyAInput
|
||||
partyB: ContractPartyBInput
|
||||
cancelHoursLimit: number
|
||||
}
|
||||
|
||||
export function formatYuan(fen: number): string {
|
||||
const sign = fen < 0 ? '-' : ''
|
||||
const [integer, decimal] = (Math.abs(fen) / 100).toFixed(2).split('.')
|
||||
return `${sign}${integer.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}.${decimal}`
|
||||
}
|
||||
|
||||
export function toChineseAmount(fen: number): string {
|
||||
const digits = '零壹贰叁肆伍陆柒捌玖'
|
||||
const units = ['', '拾', '佰', '仟']
|
||||
const sections = ['', '万', '亿']
|
||||
const yuan = Math.floor(fen / 100)
|
||||
const jiao = Math.floor((fen % 100) / 10)
|
||||
const cent = fen % 10
|
||||
|
||||
let integer = ''
|
||||
if (yuan === 0) {
|
||||
integer = '零'
|
||||
} else {
|
||||
const text = String(yuan)
|
||||
let zeroPending = false
|
||||
for (let index = 0; index < text.length; index++) {
|
||||
const digit = Number(text[index])
|
||||
const position = text.length - 1 - index
|
||||
if (digit === 0) {
|
||||
zeroPending = true
|
||||
} else {
|
||||
if (zeroPending && integer) integer += '零'
|
||||
zeroPending = false
|
||||
integer += digits[digit] + units[position % 4]
|
||||
}
|
||||
if (position % 4 === 0 && position > 0) {
|
||||
const sectionValue = Number(text.slice(Math.max(0, index - 3), index + 1))
|
||||
if (sectionValue !== 0) integer += sections[position / 4]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (jiao === 0 && cent === 0) return `人民币${integer}元整`
|
||||
const jiaoText = jiao ? `${digits[jiao]}角` : cent ? '零' : ''
|
||||
const centText = cent ? `${digits[cent]}分` : ''
|
||||
return `人民币${integer}元${jiaoText}${centText}`
|
||||
}
|
||||
|
||||
function chinaParts(iso: string) {
|
||||
const date = new Date(new Date(iso).getTime() + CHINA_OFFSET_MS)
|
||||
return {
|
||||
year: date.getUTCFullYear(),
|
||||
month: String(date.getUTCMonth() + 1).padStart(2, '0'),
|
||||
day: String(date.getUTCDate()).padStart(2, '0'),
|
||||
hour: String(date.getUTCHours()).padStart(2, '0'),
|
||||
minute: String(date.getUTCMinutes()).padStart(2, '0'),
|
||||
}
|
||||
}
|
||||
|
||||
export function formatChinaDate(iso: string): string {
|
||||
const { year, month, day } = chinaParts(iso)
|
||||
return `${year}年${month}月${day}日`
|
||||
}
|
||||
|
||||
export function formatChinaDateTime(iso: string): string {
|
||||
const { year, month, day, hour, minute } = chinaParts(iso)
|
||||
return `${year}年${month}月${day}日 ${hour}:${minute}`
|
||||
}
|
||||
|
||||
/** 数据库 DATE 列以 UTC 日历值保存。 */
|
||||
function formatCalendarDate(iso: string): string {
|
||||
const date = new Date(iso)
|
||||
return `${date.getUTCFullYear()}年${String(date.getUTCMonth() + 1).padStart(2, '0')}月${String(date.getUTCDate()).padStart(2, '0')}日`
|
||||
}
|
||||
|
||||
export function maskOpenid(openid: string): string {
|
||||
if (openid.length <= 8) return openid
|
||||
return `${openid.slice(0, 4)}****${openid.slice(-4)}`
|
||||
}
|
||||
|
||||
function orPending(value: string | null | undefined): string {
|
||||
return value && value.trim() ? value.trim() : PENDING_TEXT
|
||||
}
|
||||
|
||||
function lessonText(count: number | null): string {
|
||||
return count === null ? '有效期内不限次数' : `${count} 节`
|
||||
}
|
||||
|
||||
function clauses(items: Array<string | ContractClause | false | null | undefined>): ContractClause[] {
|
||||
return items
|
||||
.filter((item): item is string | ContractClause => !!item)
|
||||
.map((item, index) => {
|
||||
const clause = typeof item === 'string' ? { text: item } : item
|
||||
return { ...clause, text: `${index + 1}. ${clause.text}` }
|
||||
})
|
||||
}
|
||||
|
||||
function bold(text: string): ContractClause {
|
||||
return { text, emphasis: true }
|
||||
}
|
||||
|
||||
function buildPartyARows(partyA: ContractPartyAInput): ContractRow[] {
|
||||
return [
|
||||
{ label: '姓名', value: orPending(partyA.realName) },
|
||||
{ label: '手机号', value: orPending(partyA.phone) },
|
||||
{ label: '微信账号标识', value: maskOpenid(partyA.openid) },
|
||||
]
|
||||
}
|
||||
|
||||
function buildPartyBRows(partyB: ContractPartyBInput): ContractRow[] {
|
||||
const premises = partyB.premisesType === 'OWNED'
|
||||
? '自有'
|
||||
: partyB.leaseEndDate
|
||||
? `租赁(租期至 ${formatCalendarDate(partyB.leaseEndDate)})`
|
||||
: '租赁'
|
||||
const rows: ContractRow[] = [
|
||||
{ label: '经营者名称', value: `${orPending(partyB.businessName)}(个体工商户)` },
|
||||
{ label: '统一社会信用代码', value: orPending(partyB.creditCode) },
|
||||
{ label: '经营者', value: orPending(partyB.operatorName) },
|
||||
]
|
||||
if (partyB.brandName.trim()) rows.push({ label: '门店名称', value: partyB.brandName.trim() })
|
||||
rows.push(
|
||||
{ label: '联系电话', value: orPending(partyB.phone) },
|
||||
{ label: '经营场所', value: orPending(partyB.address) },
|
||||
{ label: '场所性质', value: premises },
|
||||
)
|
||||
return rows
|
||||
}
|
||||
|
||||
function buildCardRows(input: ContractRenderInput): ContractRow[] {
|
||||
const { terms, partyB, source } = input
|
||||
const isMonthly = terms.kind === 'MONTHLY'
|
||||
const rows: ContractRow[] = [
|
||||
{ label: '卡种名称', value: terms.cardName },
|
||||
{ label: '卡种类别', value: isMonthly ? '月卡' : '多次卡' },
|
||||
{ label: '服务内容', value: '普拉提课程训练服务(课程时段、单节时长以乙方小程序发布的课程为准)' },
|
||||
{ label: '服务地点', value: orPending(partyB.address) },
|
||||
]
|
||||
|
||||
if (source === MembershipContractSource.LEGACY) {
|
||||
rows.push(
|
||||
{ label: '购买课时', value: terms.lessonCount === null ? '有效期内不限次数' : `${terms.lessonCount} 节(含续购累计)` },
|
||||
{
|
||||
label: '剩余课时',
|
||||
value: terms.legacyRemainingTimes === null
|
||||
? '有效期内不限次数'
|
||||
: `${terms.legacyRemainingTimes} 节(截至 ${terms.legacyAsOf ? formatChinaDate(terms.legacyAsOf) : PENDING_TEXT})`,
|
||||
},
|
||||
{ label: '已付金额', value: `¥${formatYuan(terms.amount)}(${toChineseAmount(terms.amount)})` },
|
||||
{ label: '支付方式', value: terms.orderNos.length ? '微信支付' : '以乙方收款记录为准' },
|
||||
)
|
||||
if (terms.orderNos.length) rows.push({ label: '订单编号', value: terms.orderNos.join('、') })
|
||||
} else {
|
||||
rows.push(
|
||||
{ label: isMonthly ? '每期课时' : '课时数量', value: lessonText(terms.lessonCount) },
|
||||
{ label: '有效天数', value: `${terms.durationDays} 天` },
|
||||
{ label: '卡种售价', value: `¥${formatYuan(terms.listPrice)}` },
|
||||
{
|
||||
label: '实付金额',
|
||||
value: `¥${formatYuan(terms.amount)}(${toChineseAmount(terms.amount)})${terms.discountNote ? `,${terms.discountNote}` : ''}`,
|
||||
},
|
||||
{ label: '支付方式', value: '微信支付(乙方小程序在线收款)' },
|
||||
{ label: '订单编号', value: terms.orderNos[0] ?? '支付后生成' },
|
||||
{ label: '支付时间', value: terms.paidAt ? formatChinaDateTime(terms.paidAt) : '以微信支付确认时间为准' },
|
||||
)
|
||||
}
|
||||
|
||||
rows.push(
|
||||
{ label: '权益生效时间', value: terms.startDate ? formatChinaDateTime(terms.startDate) : '微信支付确认后立即生效' },
|
||||
{
|
||||
label: '权益截止时间',
|
||||
value: terms.expireDate ? formatChinaDateTime(terms.expireDate) : `自生效时间起 ${terms.durationDays} 天`,
|
||||
},
|
||||
)
|
||||
|
||||
if (terms.renewal && terms.availableTimesAfter !== null) {
|
||||
rows.push({
|
||||
label: '续购说明',
|
||||
value: `本次为续购,新增 ${terms.lessonCount ?? 0} 节课时并入原会员卡,续购后合计可用 ${terms.availableTimesAfter} 节,有效期至上述截止时间`,
|
||||
})
|
||||
}
|
||||
if (terms.scheduled) {
|
||||
rows.push({ label: '排期说明', value: '本期为提前续购的下期月卡,自上一期届满时起生效' })
|
||||
}
|
||||
|
||||
rows.push({
|
||||
label: '单节标准课价',
|
||||
value: partyB.standardLessonPrice > 0
|
||||
? `¥${formatYuan(partyB.standardLessonPrice)}(乙方公示的单次课价格,仅用于第六条第 2 款退费结算)`
|
||||
: PENDING_TEXT,
|
||||
})
|
||||
return rows
|
||||
}
|
||||
|
||||
function buildSections(input: ContractRenderInput): ContractSection[] {
|
||||
const { terms, partyB, source, cancelHoursLimit } = input
|
||||
const isMonthly = terms.kind === 'MONTHLY'
|
||||
const unlimited = terms.lessonCount === null
|
||||
const standardPrice = partyB.standardLessonPrice > 0 ? `¥${formatYuan(partyB.standardLessonPrice)}` : '单节标准课价'
|
||||
const acceptedAt = terms.acceptedAt ? `已于 ${formatChinaDateTime(terms.acceptedAt)} ` : ''
|
||||
|
||||
return [
|
||||
{
|
||||
title: '第一条 合同成立与生效',
|
||||
clauses: clauses([
|
||||
`本合同由乙方通过微信小程序向甲方提供。甲方${acceptedAt}在支付前阅读并同意本合同条款,并在核对本合同全文后以手写电子签名方式签署;乙方签章已预先加载于本合同。本合同自甲方签署之日起生效。`,
|
||||
source === MembershipContractSource.LEGACY
|
||||
? '甲方在本合同签署前已向乙方购买会员卡,双方确认此前的付款、预约、上课及课时扣减记录真实有效;自本合同签署之日起,双方就剩余权益按本合同履行。'
|
||||
: '甲方在本合同签署前已完成付款的,付款时间及会员卡权益生效时间以本合同「会员卡信息」所载为准;签署前已发生的预约、上课及课时扣减记录,双方确认有效并适用本合同。',
|
||||
'本合同履行期限自会员卡权益生效之时起,至权益截止时间届满或课时全部使用完毕之时止(以先到者为准);依本合同办理退费或转让完毕的,本合同自办理完毕之日起终止。',
|
||||
'甲方可随时在乙方小程序「个人中心 - 我的合同」查看、下载本合同及签署记录。',
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: '第二条 会员卡权益与有效期',
|
||||
clauses: clauses([
|
||||
isMonthly
|
||||
? `本卡为月卡,每期有效期 ${terms.durationDays} 天,每期课时为${unlimited ? '有效期内不限次数' : ` ${terms.lessonCount} 节`}。每期课时仅限当期使用,未使用的课时不结转至下一期。`
|
||||
: `本卡为多次卡,共含课时 ${terms.lessonCount ?? 0} 节,自付款确认时起生效,有效期 ${terms.durationDays} 天,具体起止时间以「会员卡信息」所载为准。`,
|
||||
isMonthly
|
||||
? '上一期月卡尚未到期时购买的,新一期自上一期届满时起生效;当期课时用完后,甲方可在小程序中确认提前启用下一期,下一期自启用时起计算完整期限。各期生效与截止时间以小程序记录为准。'
|
||||
: '在有效期内续购同一卡种的,新增课时并入原会员卡,有效期按续购时小程序公示的规则相应延长,并以续购时签署的合同为准;已过期会员卡的剩余课时不并入新购卡。',
|
||||
bold('因甲方自身原因未在有效期内使用的课时,在有效期届满后失效,乙方不再提供相应服务,也不予退费;甲方在有效期届满前已按第七条申请延期或暂停的除外。'),
|
||||
'乙方在小程序中实时展示剩余课时、有效期及预约、上课记录,甲方可随时查询。',
|
||||
'本会员卡为记名卡,仅限甲方本人使用,甲方不得将会员卡借予他人使用。',
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: '第三条 课程预约与使用',
|
||||
clauses: clauses([
|
||||
`甲方通过乙方微信小程序预约课程,预约经乙方确认后成立。${unlimited ? '' : '乙方确认预约时扣除 1 节课时。'}`,
|
||||
bold(`已确认的预约,甲方在课程开始前 ${cancelHoursLimit} 小时以前取消的,已扣课时自动退回;在课程开始前 ${cancelHoursLimit} 小时内取消或未到场上课的,该节课时视为已使用。尚未经乙方确认的预约,取消不扣课时。`),
|
||||
'甲方因突发疾病等紧急情况无法按时上课的,应及时联系乙方说明,乙方可视情况退回该节课时。',
|
||||
'甲方迟到的,课程按原定时间结束,不予顺延。',
|
||||
'因乙方原因(如授课人员临时无法授课、场地设施故障等)取消或调整课程的,乙方应尽快通知甲方并退回相应课时,由甲方另行预约。',
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: '第四条 乙方的权利和义务',
|
||||
clauses: clauses([
|
||||
'乙方为依法登记的个体工商户,按本合同约定在经营场所为甲方提供普拉提课程服务,授课人员应接受过普拉提专业培训。',
|
||||
'乙方应保证器械、设施符合安全使用要求并定期维护保养,在训练中给予必要的安全指导和保护。',
|
||||
'乙方不得在合同履行期间单方提高价格、减少课时或降低服务标准。',
|
||||
bold('乙方决定停业或迁移经营场所的,应至少提前 30 日在经营场所及小程序显著位置公告并通知甲方;甲方可选择继续履行或解除合同,选择解除的,按第六条第 3 款退款。'),
|
||||
'乙方出现可能影响正常提供服务的重大经营风险时,应当停止收取预付款。',
|
||||
'乙方仅为履行本合同收集、使用甲方的姓名、手机号及预约、上课记录等信息,并采取必要措施保护信息安全,除法律法规另有规定外不向第三方提供。身体评估、照片等信息由甲方另行授权。',
|
||||
'因乙方过错造成甲方人身或财产损害的,乙方依法承担相应责任。',
|
||||
'乙方发现甲方身体状况明显不适宜训练时,有权暂停当节训练并建议甲方就医,因此暂停的课程不扣除课时。',
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: '第五条 甲方的权利和义务',
|
||||
clauses: clauses([
|
||||
'甲方应如实填写姓名及联系方式,信息变更时应及时告知乙方。',
|
||||
'甲方应遵守场馆安全规定,按授课人员指导规范使用器械,训练前如实告知身体状况。',
|
||||
'甲方应妥善保管随身物品,贵重物品请勿带入场馆;因乙方过错造成物品损失的,乙方依法承担相应责任。',
|
||||
'出于训练安全考虑,甲方不得携带 12 周岁以下儿童进入训练区域。',
|
||||
'甲方享有查询消费记录、剩余课时及有效期的权利,并可依本合同申请延期、暂停、转让和退费。',
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: '第六条 退费',
|
||||
clauses: clauses([
|
||||
bold('七日无理由退款:甲方自付款之日起 7 日内(含第 7 日),且付款后未上课、未发生课时扣减的,可申请退还全部实付金额,乙方不收取任何费用。本款同样适用于续购订单。'),
|
||||
bold(
|
||||
unlimited
|
||||
? '甲方原因退费:超过前款期限或已上课的,甲方可随时申请解除合同并退费。退款金额 = 实付金额 × 剩余有效天数 ÷ 总有效天数;尚未生效的期次全额退还。乙方不收取手续费、违约金或其他费用。'
|
||||
: `甲方原因退费:超过前款期限或已上课的,甲方可随时申请解除合同并退费。退款金额 = 实付金额 − 已使用课时数 × 单节标准课价(${standardPrice});计算结果小于零时按零计,乙方不向甲方追收差额。乙方不收取手续费、违约金或其他费用。${
|
||||
isMonthly
|
||||
? '月卡按期计算,已使用课时数指本合同对应期次已扣减的课时,尚未生效的期次全额退还。'
|
||||
: terms.renewal
|
||||
? '续购课时并入原会员卡的,已使用课时按扣减先后顺序优先计入较早购买的课时。'
|
||||
: ''
|
||||
}`,
|
||||
),
|
||||
bold(
|
||||
`乙方原因退费:因乙方停业、迁址给甲方造成明显不便、无法按约提供服务、降低服务标准,或未经甲方同意将合同义务转让给第三方等原因解除合同的,${unlimited ? '按剩余有效天数占总有效天数的比例退还实付金额' : '退款金额 = 实付金额 − 已使用课时数 × 本卡实付单价(实付金额 ÷ 课时总数)'},乙方并依法支付相应利息。`,
|
||||
),
|
||||
'甲方因怀孕、伤病等身体健康原因(需提供医疗机构证明)不宜继续训练的,可选择按第七条暂停,或按本条第 2 款解除合同。',
|
||||
'退费申请可通过小程序或乙方联系电话提出。乙方应在双方确认退款金额后 7 个工作日内通过微信支付原路退回;退款完成后,对应会员卡剩余权益作废,本合同终止。',
|
||||
'乙方赠送的课时或权益(如有)不计入已使用课时,也不折算退款,合同解除后一并失效。',
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: '第七条 延期、暂停与转让',
|
||||
clauses: clauses([
|
||||
'甲方可在有效期内申请延期 1 次,延期不超过 30 天,乙方不收取费用。',
|
||||
'甲方因怀孕、伤病等原因(需提供医疗机构证明)暂时不宜训练的,可申请暂停,暂停期间有效期相应顺延,暂停期限由双方根据医嘱协商确定。',
|
||||
`甲方可将本卡剩余权益一次性整体转让给他人,转让自甲方通知乙方时对乙方生效,乙方不收取转让费用。受让人应在乙方小程序注册并确认本合同条款后预约课程,承继本卡剩余课时与有效期。${unlimited ? '不限次卡不得由多人分用。' : ''}`,
|
||||
'延期、暂停及转让的结果以乙方在小程序中的记录为准。',
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: '第八条 健康声明与运动安全',
|
||||
clauses: clauses([
|
||||
bold('甲方确认不存在以下不适宜普拉提运动的情形:严重心脑血管疾病、未控制的高血压或糖尿病、严重骨质疏松、急性损伤或手术恢复期、癫痫、传染性疾病、精神类疾病发作期,以及医生建议避免运动的其他情形。如存在上述情形,或处于孕期、产后恢复期,甲方应在上课前告知乙方并遵医嘱训练。'),
|
||||
'甲方不得在饮酒后或身体明显不适时参加训练;训练中如有不适,应立即告知授课人员并停止训练。',
|
||||
'乙方提供的是运动训练服务,不提供医疗诊断或治疗,不对训练效果作出疗效承诺。',
|
||||
bold('甲方隐瞒或未如实告知健康状况,导致自身损害的,由甲方自行承担相应责任;乙方有过错的,依法承担相应责任。'),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: '第九条 电子签署与通知',
|
||||
clauses: clauses([
|
||||
'双方同意以电子形式订立本合同。甲方通过本人微信账号登录乙方小程序,核对身份信息后手写电子签名,与在纸质合同上签字具有同等法律效力。',
|
||||
'乙方系统记录签署时间、合同内容摘要(SHA-256)、签名摘要及签署设备信息作为签署凭证;签署后合同内容不可修改。',
|
||||
'乙方通过小程序消息或甲方预留手机号向甲方发送通知;甲方联系方式变更的,应及时更新。',
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: '第十条 违约责任与争议解决',
|
||||
clauses: clauses([
|
||||
'任何一方违反本合同约定的,应依法承担违约责任。',
|
||||
'履行本合同发生争议的,双方协商解决;协商不成的,甲方可向消费者协会或通过 12315 平台投诉申请调解,任何一方也可依法向有管辖权的人民法院提起诉讼。',
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: '第十一条 其他',
|
||||
clauses: clauses([
|
||||
bold('乙方已就本合同中加粗显示的条款(包括有效期、取消预约、退费、健康声明等与甲方有重大利害关系的内容)向甲方作出提示,甲方可随时要求乙方予以说明。'),
|
||||
'本合同未尽事宜,双方可另行书面约定;补充约定与本合同不一致的,以补充约定为准,但不得减损甲方依法享有的权利。',
|
||||
'本合同电子文本由双方各自留存,具有同等效力。',
|
||||
]),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function buildContractDocument(input: ContractRenderInput): ContractDocument {
|
||||
return {
|
||||
title: input.source === MembershipContractSource.LEGACY ? '普拉提会员卡服务合同(存量会员补签)' : '普拉提会员卡服务合同',
|
||||
contractNo: input.contractNo,
|
||||
templateVersion: CONTRACT_TEMPLATE_VERSION,
|
||||
notice: '签署提示:本合同依据《中华人民共和国民法典》《中华人民共和国消费者权益保护法》《消费者权益保护法实施条例》等规定订立。请您仔细阅读全部条款,加粗条款与您的权益密切相关,请重点阅读;如有疑问,可在签署前联系乙方说明。',
|
||||
partyA: buildPartyARows(input.partyA),
|
||||
partyB: buildPartyBRows(input.partyB),
|
||||
cardRows: buildCardRows(input),
|
||||
sections: buildSections(input),
|
||||
commitment: '甲方承诺:本人确认所填写的姓名、联系方式真实有效,已如实告知身体健康状况,已仔细阅读并理解本合同全部条款(特别是加粗条款),自愿签署本合同。',
|
||||
}
|
||||
}
|
||||
|
||||
export function hashContractDocument(document: ContractDocument): string {
|
||||
return createHash('sha256').update(JSON.stringify(document)).digest('hex')
|
||||
}
|
||||
126
packages/server/src/contract/contract.controller.ts
Normal file
126
packages/server/src/contract/contract.controller.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { Body, Controller, Get, Param, Post, Put, Query, Req, Res, UseGuards } from '@nestjs/common'
|
||||
import type { Request, Response } from 'express'
|
||||
import { UserRole } from '@mp-pilates/shared'
|
||||
import { JwtAuthGuard } from '../auth/jwt-auth.guard'
|
||||
import { RolesGuard } from '../auth/roles.guard'
|
||||
import { Roles } from '../auth/roles.decorator'
|
||||
import { CurrentUser } from '../common/decorators/current-user.decorator'
|
||||
import { ContractRequestMeta, ContractService, ContractViewer } from './contract.service'
|
||||
import {
|
||||
AdminContractQueryDto,
|
||||
CreateLegacyContractDto,
|
||||
SavePartyADto,
|
||||
SignContractDto,
|
||||
TemplatePreviewQueryDto,
|
||||
UpdateContractPartyDto,
|
||||
UpdatePartySignatureDto,
|
||||
VoidContractDto,
|
||||
} from './dto/contract.dto'
|
||||
|
||||
interface JwtUser {
|
||||
sub: string
|
||||
role: UserRole
|
||||
}
|
||||
|
||||
function viewerOf(user: JwtUser): ContractViewer {
|
||||
return { id: user.sub, isAdmin: user.role === UserRole.ADMIN }
|
||||
}
|
||||
|
||||
function metaOf(request: Request): ContractRequestMeta {
|
||||
const forwarded = request.headers['x-forwarded-for']
|
||||
const ip = (Array.isArray(forwarded) ? forwarded[0] : forwarded)?.split(',')[0]?.trim() || request.ip || null
|
||||
const userAgent = request.headers['user-agent']?.slice(0, 300) ?? null
|
||||
return { ip: ip ? ip.slice(0, 64) : null, userAgent }
|
||||
}
|
||||
|
||||
@Controller('contracts')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class ContractController {
|
||||
constructor(private readonly contractService: ContractService) {}
|
||||
|
||||
@Get('mine')
|
||||
listMine(@CurrentUser('sub') userId: string) {
|
||||
return this.contractService.listMine(userId)
|
||||
}
|
||||
|
||||
@Get('pending-summary')
|
||||
pendingSummary(@CurrentUser('sub') userId: string) {
|
||||
return this.contractService.getPendingSummary(userId)
|
||||
}
|
||||
|
||||
@Get('template-preview')
|
||||
templatePreview(@CurrentUser('sub') userId: string, @Query() query: TemplatePreviewQueryDto) {
|
||||
return this.contractService.templatePreview(userId, query.cardTypeId)
|
||||
}
|
||||
|
||||
@Get('by-order/:orderId')
|
||||
findByOrder(@CurrentUser('sub') userId: string, @Param('orderId') orderId: string) {
|
||||
return this.contractService.findByOrder(userId, orderId)
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detail(@CurrentUser() user: JwtUser, @Param('id') id: string, @Req() request: Request) {
|
||||
return this.contractService.getDetail(id, viewerOf(user), metaOf(request))
|
||||
}
|
||||
|
||||
@Put(':id/party-a')
|
||||
savePartyA(@CurrentUser('sub') userId: string, @Param('id') id: string, @Body() dto: SavePartyADto) {
|
||||
return this.contractService.savePartyA(userId, id, dto)
|
||||
}
|
||||
|
||||
@Post(':id/sign')
|
||||
sign(@CurrentUser('sub') userId: string, @Param('id') id: string, @Body() dto: SignContractDto, @Req() request: Request) {
|
||||
return this.contractService.sign(userId, id, dto, metaOf(request))
|
||||
}
|
||||
|
||||
@Get(':id/pdf')
|
||||
async pdf(@CurrentUser() user: JwtUser, @Param('id') id: string, @Req() request: Request, @Res() response: Response) {
|
||||
const { buffer, fileName } = await this.contractService.exportPdf(id, viewerOf(user), metaOf(request))
|
||||
response.setHeader('Content-Type', 'application/pdf')
|
||||
response.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`)
|
||||
response.setHeader('Cache-Control', 'no-store')
|
||||
response.send(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('admin')
|
||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
export class ContractAdminController {
|
||||
constructor(private readonly contractService: ContractService) {}
|
||||
|
||||
@Get('contract-party')
|
||||
getParty() {
|
||||
return this.contractService.getPartyConfig()
|
||||
}
|
||||
|
||||
@Put('contract-party')
|
||||
updateParty(@Body() dto: UpdateContractPartyDto) {
|
||||
return this.contractService.updatePartyConfig(dto)
|
||||
}
|
||||
|
||||
@Put('contract-party/signature')
|
||||
updatePartySignature(@Body() dto: UpdatePartySignatureDto) {
|
||||
return this.contractService.updatePartySignature(dto.image)
|
||||
}
|
||||
|
||||
@Get('contracts')
|
||||
list(@Query() query: AdminContractQueryDto) {
|
||||
return this.contractService.adminList(query)
|
||||
}
|
||||
|
||||
@Post('contracts/legacy-sync')
|
||||
syncLegacy() {
|
||||
return this.contractService.syncLegacyContracts()
|
||||
}
|
||||
|
||||
@Post('contracts/legacy')
|
||||
createLegacy(@CurrentUser('sub') adminId: string, @Body() dto: CreateLegacyContractDto) {
|
||||
return this.contractService.createLegacyByAdmin(adminId, dto)
|
||||
}
|
||||
|
||||
@Post('contracts/:id/void')
|
||||
voidContract(@CurrentUser('sub') adminId: string, @Param('id') id: string, @Body() dto: VoidContractDto) {
|
||||
return this.contractService.voidContract(adminId, id, dto.reason)
|
||||
}
|
||||
}
|
||||
14
packages/server/src/contract/contract.module.ts
Normal file
14
packages/server/src/contract/contract.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Module } from '@nestjs/common'
|
||||
import { PrismaModule } from '../prisma/prisma.module'
|
||||
import { StudioModule } from '../studio/studio.module'
|
||||
import { ContractAdminController, ContractController } from './contract.controller'
|
||||
import { ContractPdfService } from './contract-pdf.service'
|
||||
import { ContractService } from './contract.service'
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, StudioModule],
|
||||
controllers: [ContractController, ContractAdminController],
|
||||
providers: [ContractService, ContractPdfService],
|
||||
exports: [ContractService],
|
||||
})
|
||||
export class ContractModule {}
|
||||
701
packages/server/src/contract/contract.service.ts
Normal file
701
packages/server/src/contract/contract.service.ts
Normal file
@@ -0,0 +1,701 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ConflictException,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common'
|
||||
import { CardType, ContractParty, Membership, MembershipContract, Order, Prisma } from '@prisma/client'
|
||||
import { createHash, randomInt } from 'crypto'
|
||||
import {
|
||||
CardTypeCategory,
|
||||
CONTRACT_SIGNATURE_MAX_BYTES,
|
||||
MembershipContractSource,
|
||||
MembershipContractStatus,
|
||||
MembershipStatus,
|
||||
OrderStatus,
|
||||
getMembershipEffectiveState,
|
||||
requiresMembershipContract,
|
||||
type AdminMembershipContractRow,
|
||||
type ContractDocument,
|
||||
type ContractPartyConfig,
|
||||
type ContractPendingSummary,
|
||||
type LegacyContractCandidate,
|
||||
type LegacyContractSyncResult,
|
||||
type MembershipContractDetail,
|
||||
type MembershipContractSummary,
|
||||
type PaginatedData,
|
||||
} from '@mp-pilates/shared'
|
||||
import { PrismaService } from '../prisma/prisma.service'
|
||||
import { StudioService } from '../studio/studio.service'
|
||||
import { ContractPdfService } from './contract-pdf.service'
|
||||
import {
|
||||
CONTRACT_TEMPLATE_VERSION,
|
||||
buildContractDocument,
|
||||
hashContractDocument,
|
||||
type ContractPartyBInput,
|
||||
type ContractTerms,
|
||||
} from './contract-template'
|
||||
import {
|
||||
AdminContractQueryDto,
|
||||
CreateLegacyContractDto,
|
||||
SavePartyADto,
|
||||
SignContractDto,
|
||||
UpdateContractPartyDto,
|
||||
} from './dto/contract.dto'
|
||||
|
||||
export interface ContractViewer {
|
||||
id: string
|
||||
isAdmin: boolean
|
||||
}
|
||||
|
||||
export interface ContractRequestMeta {
|
||||
ip: string | null
|
||||
userAgent: string | null
|
||||
}
|
||||
|
||||
type ContractWithUser = MembershipContract & {
|
||||
user: { id: string; nickname: string; phone: string | null; openid: string }
|
||||
}
|
||||
|
||||
type LegacyMembership = Membership & {
|
||||
cardType: CardType
|
||||
user: { id: string; nickname: string; phone: string | null }
|
||||
orders: Order[]
|
||||
contracts: Array<{ orderId: string | null; source: string }>
|
||||
}
|
||||
|
||||
const PNG_MAGIC = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
|
||||
const SIGNATURE_MIN_BYTES = 500
|
||||
const USER_SELECT = { id: true, nickname: true, phone: true, openid: true } as const
|
||||
|
||||
function sha256(input: Buffer | string): string {
|
||||
return createHash('sha256').update(input).digest('hex')
|
||||
}
|
||||
|
||||
function toDataUri(image: Uint8Array | Buffer | null): string | null {
|
||||
return image ? `data:image/png;base64,${Buffer.from(image).toString('base64')}` : null
|
||||
}
|
||||
|
||||
export function decodeSignaturePng(input: string): Buffer {
|
||||
const base64 = input.replace(/^data:image\/png;base64,/, '')
|
||||
const image = Buffer.from(base64, 'base64')
|
||||
if (image.length < SIGNATURE_MIN_BYTES || image.length > CONTRACT_SIGNATURE_MAX_BYTES || !image.subarray(0, 8).equals(PNG_MAGIC)) {
|
||||
throw new BadRequestException('签名图片无效,请重新签名')
|
||||
}
|
||||
return image
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ContractService {
|
||||
private readonly logger = new Logger(ContractService.name)
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly studioService: StudioService,
|
||||
private readonly pdfService: ContractPdfService,
|
||||
) {}
|
||||
|
||||
// ─── Issuance ──────────────────────────────────────────────────────────────
|
||||
|
||||
async issueForPaidOrder(
|
||||
tx: Prisma.TransactionClient,
|
||||
params: { order: Order; cardType: CardType; membership: Membership; paidAt: Date },
|
||||
): Promise<MembershipContract | null> {
|
||||
const { order, cardType, membership, paidAt } = params
|
||||
if (!requiresMembershipContract(cardType)) return null
|
||||
const existing = await tx.membershipContract.findUnique({ where: { orderId: order.id } })
|
||||
if (existing) return existing
|
||||
|
||||
// Times cards reuse the earlier record; expired balances are reset rather than merged.
|
||||
const reusedRecord = cardType.type === CardTypeCategory.TIMES && membership.createdAt < order.createdAt
|
||||
const renewal = reusedRecord && membership.remainingTimes !== cardType.totalTimes
|
||||
const listPrice = Number(cardType.price)
|
||||
const amount = Number(order.amount)
|
||||
const terms: ContractTerms = {
|
||||
kind: cardType.type === CardTypeCategory.DURATION ? 'MONTHLY' : 'MULTI_TIMES',
|
||||
cardTypeId: cardType.id,
|
||||
cardName: cardType.name,
|
||||
lessonCount: cardType.totalTimes,
|
||||
durationDays: cardType.durationDays,
|
||||
listPrice,
|
||||
amount,
|
||||
discountNote: amount < listPrice ? (order.inviteInviterId ? '已享好友邀请 95 折' : '已享优惠') : null,
|
||||
orderNos: [order.orderNo],
|
||||
paidAt: paidAt.toISOString(),
|
||||
acceptedAt: order.contractAcceptedAt?.toISOString() ?? null,
|
||||
startDate: (reusedRecord ? paidAt : membership.startDate).toISOString(),
|
||||
expireDate: membership.expireDate.toISOString(),
|
||||
scheduled: membership.startDate > paidAt,
|
||||
renewal,
|
||||
availableTimesAfter: renewal ? membership.remainingTimes : null,
|
||||
legacyRemainingTimes: null,
|
||||
legacyAsOf: null,
|
||||
}
|
||||
return this.createContract(tx, {
|
||||
userId: order.userId,
|
||||
membershipId: membership.id,
|
||||
orderId: order.id,
|
||||
source: MembershipContractSource.PURCHASE,
|
||||
requireBeforeBooking: true,
|
||||
terms,
|
||||
createdBy: null,
|
||||
})
|
||||
}
|
||||
|
||||
private async createContract(
|
||||
tx: Prisma.TransactionClient,
|
||||
data: {
|
||||
userId: string
|
||||
membershipId: string
|
||||
orderId: string | null
|
||||
source: MembershipContractSource
|
||||
requireBeforeBooking: boolean
|
||||
terms: ContractTerms
|
||||
createdBy: string | null
|
||||
},
|
||||
): Promise<MembershipContract> {
|
||||
const contract = await tx.membershipContract.create({
|
||||
data: {
|
||||
...data,
|
||||
contractNo: await this.generateContractNo(tx),
|
||||
templateVersion: CONTRACT_TEMPLATE_VERSION,
|
||||
terms: data.terms as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
})
|
||||
await tx.contractEvent.create({
|
||||
data: { contractId: contract.id, action: 'CREATED', actorId: data.createdBy, detail: { source: data.source } },
|
||||
})
|
||||
return contract
|
||||
}
|
||||
|
||||
private async generateContractNo(tx: Prisma.TransactionClient): Promise<string> {
|
||||
const china = new Date(Date.now() + 8 * 3600 * 1000)
|
||||
const date = `${china.getUTCFullYear()}${String(china.getUTCMonth() + 1).padStart(2, '0')}${String(china.getUTCDate()).padStart(2, '0')}`
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
const contractNo = `MP${date}${String(randomInt(0, 1_000_000)).padStart(6, '0')}`
|
||||
const taken = await tx.membershipContract.findUnique({ where: { contractNo }, select: { id: true } })
|
||||
if (!taken) return contractNo
|
||||
}
|
||||
throw new ConflictException('合同编号生成失败,请重试')
|
||||
}
|
||||
|
||||
// ─── Legacy memberships ────────────────────────────────────────────────────
|
||||
|
||||
async syncLegacyContracts(userId?: string): Promise<LegacyContractSyncResult> {
|
||||
const now = new Date()
|
||||
const memberships: LegacyMembership[] = await this.prisma.membership.findMany({
|
||||
where: {
|
||||
...(userId ? { userId } : {}),
|
||||
status: { in: [MembershipStatus.ACTIVE, MembershipStatus.SCHEDULED] },
|
||||
expireDate: { gt: now },
|
||||
inviteRewardGrants: { none: {} },
|
||||
},
|
||||
include: {
|
||||
cardType: true,
|
||||
user: { select: { id: true, nickname: true, phone: true } },
|
||||
orders: { where: { status: OrderStatus.PAID }, orderBy: { paidAt: 'asc' } },
|
||||
contracts: { where: { status: { not: MembershipContractStatus.VOIDED } }, select: { orderId: true, source: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})
|
||||
|
||||
let created = 0
|
||||
const needsAmount: LegacyContractCandidate[] = []
|
||||
for (const membership of memberships) {
|
||||
if (!requiresMembershipContract(membership.cardType)) continue
|
||||
const state = getMembershipEffectiveState(membership, now)
|
||||
if (state.status !== MembershipStatus.ACTIVE && state.status !== MembershipStatus.SCHEDULED) continue
|
||||
if (membership.contracts.some(contract => contract.source === MembershipContractSource.LEGACY)) continue
|
||||
|
||||
const covered = new Set(membership.contracts.map(contract => contract.orderId))
|
||||
const uncovered = membership.orders.filter(order => !covered.has(order.id))
|
||||
if (!uncovered.length) {
|
||||
if (!membership.contracts.length) needsAmount.push(this.toLegacyCandidate(membership, state.remainingTimes))
|
||||
continue
|
||||
}
|
||||
const amount = uncovered.reduce((sum, order) => sum + Number(order.amount), 0)
|
||||
if (await this.createLegacyContract(membership.id, amount, uncovered, null)) created++
|
||||
}
|
||||
return { created, needsAmount }
|
||||
}
|
||||
|
||||
async createLegacyByAdmin(adminId: string, dto: CreateLegacyContractDto): Promise<MembershipContractSummary> {
|
||||
const membership = await this.prisma.membership.findUnique({ where: { id: dto.membershipId }, include: { cardType: true } })
|
||||
if (!membership) throw new NotFoundException('会员卡不存在')
|
||||
if (!requiresMembershipContract(membership.cardType)) throw new BadRequestException('该卡种无需签署合同')
|
||||
const state = getMembershipEffectiveState(membership)
|
||||
if (state.status !== MembershipStatus.ACTIVE && state.status !== MembershipStatus.SCHEDULED) {
|
||||
throw new BadRequestException('会员卡已过期或已用完,无需补签')
|
||||
}
|
||||
const orders = await this.prisma.order.findMany({
|
||||
where: { membershipId: membership.id, status: OrderStatus.PAID, contract: { is: null } },
|
||||
orderBy: { paidAt: 'asc' },
|
||||
})
|
||||
const contract = await this.createLegacyContract(membership.id, dto.amount, orders, adminId)
|
||||
if (!contract) throw new ConflictException('该会员卡已有补签合同')
|
||||
return this.toSummary(contract)
|
||||
}
|
||||
|
||||
private async createLegacyContract(
|
||||
membershipId: string,
|
||||
amount: number,
|
||||
orders: Order[],
|
||||
createdBy: string | null,
|
||||
): Promise<MembershipContract | null> {
|
||||
return this.prisma.$transaction(async tx => {
|
||||
const membership = await tx.membership.findUnique({ where: { id: membershipId }, include: { cardType: true } })
|
||||
if (!membership) return null
|
||||
await tx.$queryRaw`SELECT id FROM users WHERE id = ${membership.userId} FOR UPDATE`
|
||||
const duplicate = await tx.membershipContract.findFirst({
|
||||
where: {
|
||||
membershipId,
|
||||
source: MembershipContractSource.LEGACY,
|
||||
status: { not: MembershipContractStatus.VOIDED },
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
if (duplicate) return null
|
||||
|
||||
const now = new Date()
|
||||
const state = getMembershipEffectiveState(membership, now)
|
||||
const lastPaid = orders.reduce<Date | null>((latest, order) => (order.paidAt && (!latest || order.paidAt > latest) ? order.paidAt : latest), null)
|
||||
const terms: ContractTerms = {
|
||||
kind: membership.cardType.type === CardTypeCategory.DURATION ? 'MONTHLY' : 'MULTI_TIMES',
|
||||
cardTypeId: membership.cardTypeId,
|
||||
cardName: membership.cardType.name,
|
||||
lessonCount: membership.totalTimes ?? membership.cardType.totalTimes,
|
||||
durationDays: membership.cardType.durationDays,
|
||||
listPrice: Number(membership.cardType.price),
|
||||
amount,
|
||||
discountNote: null,
|
||||
orderNos: orders.map(order => order.orderNo),
|
||||
paidAt: lastPaid?.toISOString() ?? null,
|
||||
acceptedAt: null,
|
||||
startDate: membership.startDate.toISOString(),
|
||||
expireDate: membership.expireDate.toISOString(),
|
||||
scheduled: membership.startDate > now,
|
||||
renewal: false,
|
||||
availableTimesAfter: null,
|
||||
legacyRemainingTimes: state.remainingTimes,
|
||||
legacyAsOf: now.toISOString(),
|
||||
}
|
||||
return this.createContract(tx, {
|
||||
userId: membership.userId,
|
||||
membershipId,
|
||||
orderId: null,
|
||||
source: MembershipContractSource.LEGACY,
|
||||
requireBeforeBooking: false,
|
||||
terms,
|
||||
createdBy,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private toLegacyCandidate(membership: LegacyMembership, remainingTimes: number | null): LegacyContractCandidate {
|
||||
return {
|
||||
membershipId: membership.id,
|
||||
user: { ...membership.user },
|
||||
cardName: membership.cardType.name,
|
||||
kind: membership.cardType.type === CardTypeCategory.DURATION ? 'MONTHLY' : 'MULTI_TIMES',
|
||||
remainingTimes,
|
||||
totalTimes: membership.totalTimes,
|
||||
startDate: membership.startDate.toISOString(),
|
||||
expireDate: membership.expireDate.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Member ────────────────────────────────────────────────────────────────
|
||||
|
||||
async getPendingSummary(userId: string): Promise<ContractPendingSummary> {
|
||||
await this.syncLegacyQuietly(userId)
|
||||
const pending = await this.prisma.membershipContract.findMany({
|
||||
where: { userId, status: MembershipContractStatus.PENDING_SIGN },
|
||||
select: { id: true, requireBeforeBooking: true },
|
||||
orderBy: [{ requireBeforeBooking: 'desc' }, { createdAt: 'asc' }],
|
||||
})
|
||||
return {
|
||||
pendingCount: pending.length,
|
||||
blockingCount: pending.filter(item => item.requireBeforeBooking).length,
|
||||
firstPendingId: pending[0]?.id ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
async listMine(userId: string): Promise<MembershipContractSummary[]> {
|
||||
await this.syncLegacyQuietly(userId)
|
||||
const contracts = await this.prisma.membershipContract.findMany({
|
||||
where: { userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
})
|
||||
const rank = (status: string) => (status === MembershipContractStatus.PENDING_SIGN ? 0 : status === MembershipContractStatus.SIGNED ? 1 : 2)
|
||||
return contracts
|
||||
.map(contract => this.toSummary(contract))
|
||||
.sort((left, right) => rank(left.status) - rank(right.status))
|
||||
}
|
||||
|
||||
async findByOrder(userId: string, orderId: string): Promise<MembershipContractSummary | null> {
|
||||
const contract = await this.prisma.membershipContract.findFirst({ where: { orderId, userId } })
|
||||
return contract ? this.toSummary(contract) : null
|
||||
}
|
||||
|
||||
async getDetail(id: string, viewer: ContractViewer, meta?: ContractRequestMeta): Promise<MembershipContractDetail> {
|
||||
const contract = await this.loadForViewer(id, viewer)
|
||||
if (meta && contract.userId === viewer.id && contract.status === MembershipContractStatus.PENDING_SIGN) {
|
||||
await this.prisma.contractEvent.create({
|
||||
data: { contractId: id, action: 'VIEWED', actorId: viewer.id, ip: meta.ip, userAgent: meta.userAgent },
|
||||
})
|
||||
}
|
||||
return this.toDetail(contract)
|
||||
}
|
||||
|
||||
async savePartyA(userId: string, id: string, dto: SavePartyADto): Promise<MembershipContractDetail> {
|
||||
const contract = await this.loadForViewer(id, { id: userId, isAdmin: false })
|
||||
if (contract.userId !== userId) throw new NotFoundException('合同不存在')
|
||||
if (contract.status !== MembershipContractStatus.PENDING_SIGN) throw new ConflictException('合同已签署或已作废')
|
||||
const updated = await this.prisma.membershipContract.update({
|
||||
where: { id },
|
||||
data: { partyName: dto.realName, partyPhone: dto.phone },
|
||||
include: { user: { select: USER_SELECT } },
|
||||
})
|
||||
return this.toDetail(updated)
|
||||
}
|
||||
|
||||
async sign(userId: string, id: string, dto: SignContractDto, meta: ContractRequestMeta): Promise<MembershipContractDetail> {
|
||||
const contract = await this.loadForViewer(id, { id: userId, isAdmin: false })
|
||||
if (contract.userId !== userId) throw new NotFoundException('合同不存在')
|
||||
if (contract.status !== MembershipContractStatus.PENDING_SIGN) throw new ConflictException('合同已签署或已作废')
|
||||
if (!contract.partyName || !contract.partyPhone) throw new BadRequestException('请先填写姓名和手机号')
|
||||
|
||||
const { document, contentSha256, party, missingFields } = await this.renderPending(contract)
|
||||
if (missingFields.length) throw new BadRequestException('场馆合同信息尚未配置完成,请联系馆主')
|
||||
if (contentSha256 !== dto.contentSha256) throw new ConflictException('合同内容已更新,请重新阅读后签署')
|
||||
|
||||
const signature = decodeSignaturePng(dto.signatureImage)
|
||||
const signatureSha256 = sha256(signature)
|
||||
const signedAt = new Date()
|
||||
|
||||
await this.prisma.$transaction(async tx => {
|
||||
const claimed = await tx.membershipContract.updateMany({
|
||||
where: { id, status: MembershipContractStatus.PENDING_SIGN },
|
||||
data: {
|
||||
status: MembershipContractStatus.SIGNED,
|
||||
document: document as unknown as Prisma.InputJsonValue,
|
||||
contentSha256,
|
||||
signatureImage: signature,
|
||||
signatureSha256,
|
||||
partyBSignatureImage: party.signatureImage,
|
||||
partyBName: `${party.businessName}(经营者:${party.operatorName})`,
|
||||
signedAt,
|
||||
signerIp: meta.ip,
|
||||
signerUserAgent: meta.userAgent,
|
||||
signerDevice: dto.device ?? null,
|
||||
},
|
||||
})
|
||||
if (!claimed.count) throw new ConflictException('合同已签署或已作废')
|
||||
await tx.contractEvent.create({
|
||||
data: {
|
||||
contractId: id,
|
||||
action: 'SIGNED',
|
||||
actorId: userId,
|
||||
ip: meta.ip,
|
||||
userAgent: meta.userAgent,
|
||||
detail: { contentSha256, signatureSha256, partyBSignatureSha256: party.signatureSha256, device: dto.device ?? null },
|
||||
},
|
||||
})
|
||||
})
|
||||
this.logger.log(`Contract signed: ${contract.contractNo}`)
|
||||
return this.getDetail(id, { id: userId, isAdmin: false })
|
||||
}
|
||||
|
||||
async exportPdf(id: string, viewer: ContractViewer, meta: ContractRequestMeta): Promise<{ buffer: Buffer; fileName: string }> {
|
||||
const contract = await this.loadForViewer(id, viewer)
|
||||
if (contract.status !== MembershipContractStatus.SIGNED || !contract.document || !contract.signatureImage || !contract.signedAt) {
|
||||
throw new BadRequestException('合同签署完成后才能导出')
|
||||
}
|
||||
const buffer = await this.pdfService.render({
|
||||
document: contract.document as unknown as ContractDocument,
|
||||
contentSha256: contract.contentSha256 ?? '',
|
||||
signerName: contract.partyName ?? '',
|
||||
signedAt: contract.signedAt.toISOString(),
|
||||
memberSignature: Buffer.from(contract.signatureImage),
|
||||
partyBSignature: contract.partyBSignatureImage ? Buffer.from(contract.partyBSignatureImage) : null,
|
||||
partyBName: contract.partyBName ?? '',
|
||||
})
|
||||
await this.prisma.contractEvent.create({
|
||||
data: { contractId: id, action: 'EXPORTED', actorId: viewer.id, ip: meta.ip, userAgent: meta.userAgent },
|
||||
})
|
||||
return { buffer, fileName: `会员卡服务合同-${contract.contractNo}.pdf` }
|
||||
}
|
||||
|
||||
/** 购买前预览:金额按当前优惠计算,日期以支付确认为准。 */
|
||||
async templatePreview(userId: string, cardTypeId: string): Promise<{ document: ContractDocument }> {
|
||||
const [cardType, user, referral] = await Promise.all([
|
||||
this.prisma.cardType.findUnique({ where: { id: cardTypeId } }),
|
||||
this.prisma.user.findUnique({ where: { id: userId }, select: { openid: true, phone: true } }),
|
||||
this.prisma.inviteReferral.findUnique({ where: { inviteeId: userId } }),
|
||||
])
|
||||
if (!cardType || !cardType.isActive || !user) throw new NotFoundException('卡种不存在或已下架')
|
||||
if (!requiresMembershipContract(cardType)) throw new BadRequestException('该卡种无需签署合同')
|
||||
const listPrice = Number(cardType.price)
|
||||
const amount = referral ? Math.round(listPrice * 95 / 100) : listPrice
|
||||
const terms: ContractTerms = {
|
||||
kind: cardType.type === CardTypeCategory.DURATION ? 'MONTHLY' : 'MULTI_TIMES',
|
||||
cardTypeId,
|
||||
cardName: cardType.name,
|
||||
lessonCount: cardType.totalTimes,
|
||||
durationDays: cardType.durationDays,
|
||||
listPrice,
|
||||
amount,
|
||||
discountNote: referral ? '已享好友邀请 95 折' : null,
|
||||
orderNos: [],
|
||||
paidAt: null,
|
||||
acceptedAt: null,
|
||||
startDate: null,
|
||||
expireDate: null,
|
||||
scheduled: false,
|
||||
renewal: false,
|
||||
availableTimesAfter: null,
|
||||
legacyRemainingTimes: null,
|
||||
legacyAsOf: null,
|
||||
}
|
||||
const [party, studio] = await Promise.all([this.getPartyRecord(), this.studioService.getInfo()])
|
||||
const document = buildContractDocument({
|
||||
contractNo: '支付后生成',
|
||||
source: MembershipContractSource.PURCHASE,
|
||||
terms,
|
||||
partyA: { realName: null, phone: user.phone, openid: user.openid },
|
||||
partyB: await this.toPartyBInput(party),
|
||||
cancelHoursLimit: studio.cancelHoursLimit,
|
||||
})
|
||||
return { document }
|
||||
}
|
||||
|
||||
// ─── Admin ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async getPartyConfig(): Promise<ContractPartyConfig> {
|
||||
const party = await this.getPartyRecord()
|
||||
const input = await this.toPartyBInput(party)
|
||||
return {
|
||||
businessName: party.businessName,
|
||||
brandName: party.brandName,
|
||||
creditCode: party.creditCode,
|
||||
operatorName: party.operatorName,
|
||||
phone: party.phone,
|
||||
address: party.address,
|
||||
premisesType: input.premisesType,
|
||||
leaseEndDate: input.leaseEndDate,
|
||||
standardLessonPrice: input.standardLessonPrice,
|
||||
standardLessonPriceConfigured: party.standardLessonPrice > 0,
|
||||
signatureImage: toDataUri(party.signatureImage),
|
||||
signatureUpdatedAt: party.signatureUpdatedAt?.toISOString() ?? null,
|
||||
missingFields: this.missingFields(party, input),
|
||||
}
|
||||
}
|
||||
|
||||
async updatePartyConfig(dto: UpdateContractPartyDto): Promise<ContractPartyConfig> {
|
||||
const party = await this.getPartyRecord()
|
||||
const { leaseEndDate, ...rest } = dto
|
||||
await this.prisma.contractParty.update({
|
||||
where: { id: party.id },
|
||||
data: {
|
||||
...rest,
|
||||
...(leaseEndDate !== undefined ? { leaseEndDate: leaseEndDate ? new Date(leaseEndDate) : null } : {}),
|
||||
},
|
||||
})
|
||||
return this.getPartyConfig()
|
||||
}
|
||||
|
||||
async updatePartySignature(image: string): Promise<ContractPartyConfig> {
|
||||
const party = await this.getPartyRecord()
|
||||
const signature = decodeSignaturePng(image)
|
||||
await this.prisma.contractParty.update({
|
||||
where: { id: party.id },
|
||||
data: { signatureImage: signature, signatureSha256: sha256(signature), signatureUpdatedAt: new Date() },
|
||||
})
|
||||
return this.getPartyConfig()
|
||||
}
|
||||
|
||||
async adminList(query: AdminContractQueryDto): Promise<PaginatedData<AdminMembershipContractRow>> {
|
||||
const page = query.page ?? 1
|
||||
const limit = query.limit ?? 20
|
||||
const search = query.search?.trim()
|
||||
const where: Prisma.MembershipContractWhereInput = {
|
||||
...(query.status ? { status: query.status } : {}),
|
||||
...(search
|
||||
? {
|
||||
OR: [
|
||||
{ contractNo: { contains: search } },
|
||||
{ partyName: { contains: search } },
|
||||
{ user: { nickname: { contains: search } } },
|
||||
{ user: { phone: { contains: search } } },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.membershipContract.findMany({
|
||||
where,
|
||||
include: { user: { select: { id: true, nickname: true, phone: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * limit,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.membershipContract.count({ where }),
|
||||
])
|
||||
return {
|
||||
items: items.map(item => ({ ...this.toSummary(item), user: { ...item.user }, partyName: item.partyName })),
|
||||
total,
|
||||
page,
|
||||
limit,
|
||||
}
|
||||
}
|
||||
|
||||
async voidContract(adminId: string, id: string, reason: string): Promise<MembershipContractSummary> {
|
||||
const contract = await this.prisma.membershipContract.findUnique({ where: { id } })
|
||||
if (!contract) throw new NotFoundException('合同不存在')
|
||||
if (contract.status === MembershipContractStatus.VOIDED) throw new ConflictException('合同已作废')
|
||||
const updated = await this.prisma.$transaction(async tx => {
|
||||
const result = await tx.membershipContract.update({
|
||||
where: { id },
|
||||
data: { status: MembershipContractStatus.VOIDED, voidedAt: new Date(), voidedBy: adminId, voidReason: reason },
|
||||
})
|
||||
await tx.contractEvent.create({
|
||||
data: { contractId: id, action: 'VOIDED', actorId: adminId, detail: { reason, previousStatus: contract.status } },
|
||||
})
|
||||
return result
|
||||
})
|
||||
return this.toSummary(updated)
|
||||
}
|
||||
|
||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
private async syncLegacyQuietly(userId: string) {
|
||||
try {
|
||||
await this.syncLegacyContracts(userId)
|
||||
} catch (error) {
|
||||
this.logger.warn(`Legacy contract sync failed for ${userId}: ${(error as Error).message}`)
|
||||
}
|
||||
}
|
||||
|
||||
private async loadForViewer(id: string, viewer: ContractViewer): Promise<ContractWithUser> {
|
||||
const contract = await this.prisma.membershipContract.findUnique({
|
||||
where: { id },
|
||||
include: { user: { select: USER_SELECT } },
|
||||
})
|
||||
if (!contract || (!viewer.isAdmin && contract.userId !== viewer.id)) throw new NotFoundException('合同不存在')
|
||||
return contract
|
||||
}
|
||||
|
||||
private async getPartyRecord(): Promise<ContractParty> {
|
||||
const existing = await this.prisma.contractParty.findFirst()
|
||||
return existing ?? this.prisma.contractParty.create({ data: {} })
|
||||
}
|
||||
|
||||
private async toPartyBInput(party: ContractParty): Promise<ContractPartyBInput> {
|
||||
let standardLessonPrice = party.standardLessonPrice
|
||||
if (standardLessonPrice <= 0) {
|
||||
const single = await this.prisma.cardType.findFirst({
|
||||
where: { isActive: true, type: CardTypeCategory.TIMES, totalTimes: 1 },
|
||||
orderBy: { price: 'asc' },
|
||||
})
|
||||
standardLessonPrice = single ? Number(single.price) : 0
|
||||
}
|
||||
return {
|
||||
businessName: party.businessName,
|
||||
brandName: party.brandName,
|
||||
creditCode: party.creditCode,
|
||||
operatorName: party.operatorName,
|
||||
phone: party.phone,
|
||||
address: party.address,
|
||||
premisesType: party.premisesType === 'OWNED' ? 'OWNED' : 'LEASED',
|
||||
leaseEndDate: party.leaseEndDate?.toISOString() ?? null,
|
||||
standardLessonPrice,
|
||||
}
|
||||
}
|
||||
|
||||
private missingFields(party: ContractParty, input: ContractPartyBInput): string[] {
|
||||
const missing: string[] = []
|
||||
if (!party.businessName.trim()) missing.push('经营者名称')
|
||||
if (!party.creditCode.trim()) missing.push('统一社会信用代码')
|
||||
if (!party.operatorName.trim()) missing.push('经营者姓名')
|
||||
if (!party.phone.trim()) missing.push('联系电话')
|
||||
if (!party.address.trim()) missing.push('经营场所')
|
||||
if (input.standardLessonPrice <= 0) missing.push('单节标准课价')
|
||||
if (!party.signatureImage) missing.push('经营者签名')
|
||||
return missing
|
||||
}
|
||||
|
||||
private async renderPending(contract: ContractWithUser) {
|
||||
const [party, studio] = await Promise.all([this.getPartyRecord(), this.studioService.getInfo()])
|
||||
const partyB = await this.toPartyBInput(party)
|
||||
const document = buildContractDocument({
|
||||
contractNo: contract.contractNo,
|
||||
source: contract.source as MembershipContractSource,
|
||||
terms: contract.terms as unknown as ContractTerms,
|
||||
partyA: { realName: contract.partyName, phone: contract.partyPhone, openid: contract.user.openid },
|
||||
partyB,
|
||||
cancelHoursLimit: studio.cancelHoursLimit,
|
||||
})
|
||||
return { document, contentSha256: hashContractDocument(document), party, missingFields: this.missingFields(party, partyB) }
|
||||
}
|
||||
|
||||
private toSummary(contract: MembershipContract): MembershipContractSummary {
|
||||
const terms = contract.terms as unknown as ContractTerms
|
||||
return {
|
||||
id: contract.id,
|
||||
contractNo: contract.contractNo,
|
||||
status: contract.status as MembershipContractStatus,
|
||||
source: contract.source as MembershipContractSource,
|
||||
requireBeforeBooking: contract.requireBeforeBooking,
|
||||
cardName: terms.cardName,
|
||||
kind: terms.kind,
|
||||
amount: terms.amount,
|
||||
startDate: terms.startDate ?? contract.createdAt.toISOString(),
|
||||
expireDate: terms.expireDate ?? contract.createdAt.toISOString(),
|
||||
createdAt: contract.createdAt.toISOString(),
|
||||
signedAt: contract.signedAt?.toISOString() ?? null,
|
||||
voidReason: contract.voidReason,
|
||||
}
|
||||
}
|
||||
|
||||
private async toDetail(contract: ContractWithUser): Promise<MembershipContractDetail> {
|
||||
const summary = this.toSummary(contract)
|
||||
if (contract.status === MembershipContractStatus.SIGNED && contract.document && contract.signatureImage && contract.signedAt) {
|
||||
return {
|
||||
...summary,
|
||||
partyName: contract.partyName,
|
||||
partyPhone: contract.partyPhone,
|
||||
document: contract.document as unknown as ContractDocument,
|
||||
contentSha256: contract.contentSha256 ?? '',
|
||||
signature: {
|
||||
signerName: contract.partyName ?? '',
|
||||
signedAt: contract.signedAt.toISOString(),
|
||||
memberSignature: toDataUri(contract.signatureImage) ?? '',
|
||||
partyBSignature: toDataUri(contract.partyBSignatureImage),
|
||||
partyBName: contract.partyBName ?? '',
|
||||
contentSha256: contract.contentSha256 ?? '',
|
||||
},
|
||||
signable: false,
|
||||
unsignableReason: null,
|
||||
}
|
||||
}
|
||||
|
||||
const { document, contentSha256, missingFields } = await this.renderPending(contract)
|
||||
const pending = contract.status === MembershipContractStatus.PENDING_SIGN
|
||||
const unsignableReason = !pending
|
||||
? '合同已作废'
|
||||
: missingFields.length
|
||||
? '场馆合同信息尚未配置完成,请联系馆主'
|
||||
: null
|
||||
return {
|
||||
...summary,
|
||||
partyName: contract.partyName,
|
||||
partyPhone: contract.partyPhone ?? contract.user.phone,
|
||||
document,
|
||||
contentSha256,
|
||||
signature: null,
|
||||
signable: pending && !missingFields.length,
|
||||
unsignableReason,
|
||||
}
|
||||
}
|
||||
}
|
||||
137
packages/server/src/contract/dto/contract.dto.ts
Normal file
137
packages/server/src/contract/dto/contract.dto.ts
Normal file
@@ -0,0 +1,137 @@
|
||||
import { Transform, Type } from 'class-transformer'
|
||||
import {
|
||||
Equals,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Length,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateIf,
|
||||
} from 'class-validator'
|
||||
import { CONTRACT_REAL_NAME_MAX_LENGTH, CONTRACT_SIGNATURE_MAX_BYTES, MembershipContractStatus } from '@mp-pilates/shared'
|
||||
|
||||
const trim = ({ value }: { value: unknown }) => (typeof value === 'string' ? value.trim() : value)
|
||||
const MAINLAND_MOBILE = /^1\d{10}$/
|
||||
// 18 位统一社会信用代码,不含 I、O、Z、S、V。
|
||||
const CREDIT_CODE = /^[0-9A-HJ-NPQRTUWXY]{2}\d{6}[0-9A-HJ-NPQRTUWXY]{10}$/
|
||||
|
||||
export class SavePartyADto {
|
||||
@Transform(trim)
|
||||
@IsString()
|
||||
@Length(2, CONTRACT_REAL_NAME_MAX_LENGTH, { message: '请填写 2-30 个字的真实姓名' })
|
||||
realName!: string
|
||||
|
||||
@Transform(trim)
|
||||
@Matches(MAINLAND_MOBILE, { message: '请填写 11 位手机号' })
|
||||
phone!: string
|
||||
}
|
||||
|
||||
export class SignContractDto {
|
||||
@Matches(/^[a-f0-9]{64}$/)
|
||||
contentSha256!: string
|
||||
|
||||
@IsString()
|
||||
@MaxLength(Math.ceil(CONTRACT_SIGNATURE_MAX_BYTES * 1.4))
|
||||
signatureImage!: string
|
||||
|
||||
@IsBoolean()
|
||||
@Equals(true, { message: '请确认已阅读并同意合同条款' })
|
||||
agreedTerms!: boolean
|
||||
|
||||
@IsBoolean()
|
||||
@Equals(true, { message: '请确认健康声明' })
|
||||
confirmedHealth!: boolean
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
device?: string
|
||||
}
|
||||
|
||||
export class UpdateContractPartyDto {
|
||||
@IsOptional() @Transform(trim) @IsString() @MaxLength(100)
|
||||
businessName?: string
|
||||
|
||||
@IsOptional() @Transform(trim) @IsString() @MaxLength(60)
|
||||
brandName?: string
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => (typeof value === 'string' ? value.trim().toUpperCase() : value))
|
||||
@ValidateIf((_object, value) => value !== '')
|
||||
@Matches(CREDIT_CODE, { message: '统一社会信用代码格式不正确' })
|
||||
creditCode?: string
|
||||
|
||||
@IsOptional() @Transform(trim) @IsString() @MaxLength(30)
|
||||
operatorName?: string
|
||||
|
||||
@IsOptional() @Transform(trim) @IsString() @MaxLength(20)
|
||||
phone?: string
|
||||
|
||||
@IsOptional() @Transform(trim) @IsString() @MaxLength(200)
|
||||
address?: string
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['LEASED', 'OWNED'])
|
||||
premisesType?: 'LEASED' | 'OWNED'
|
||||
|
||||
@IsOptional()
|
||||
@ValidateIf((_object, value) => value !== null)
|
||||
@IsDateString()
|
||||
leaseEndDate?: string | null
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(10_000_000)
|
||||
standardLessonPrice?: number
|
||||
}
|
||||
|
||||
export class UpdatePartySignatureDto {
|
||||
@IsString()
|
||||
@MaxLength(Math.ceil(CONTRACT_SIGNATURE_MAX_BYTES * 1.4))
|
||||
image!: string
|
||||
}
|
||||
|
||||
export class CreateLegacyContractDto {
|
||||
@IsUUID()
|
||||
membershipId!: string
|
||||
|
||||
@IsInt()
|
||||
@Min(0)
|
||||
@Max(100_000_000)
|
||||
amount!: number
|
||||
}
|
||||
|
||||
export class VoidContractDto {
|
||||
@Transform(trim)
|
||||
@IsString()
|
||||
@Length(2, 200, { message: '请填写 2-200 字的作废原因' })
|
||||
reason!: string
|
||||
}
|
||||
|
||||
export class AdminContractQueryDto {
|
||||
@IsOptional()
|
||||
@IsIn(Object.values(MembershipContractStatus))
|
||||
status?: MembershipContractStatus
|
||||
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1)
|
||||
page?: number
|
||||
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) @Max(50)
|
||||
limit?: number
|
||||
|
||||
@IsOptional() @Transform(trim) @IsString() @MaxLength(50)
|
||||
search?: string
|
||||
}
|
||||
|
||||
export class TemplatePreviewQueryDto {
|
||||
@IsUUID()
|
||||
cardTypeId!: string
|
||||
}
|
||||
@@ -15,6 +15,8 @@ async function bootstrap() {
|
||||
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, { logger })
|
||||
const configService = app.get(ConfigService)
|
||||
// Contract signatures are posted as base64 PNG.
|
||||
app.useBodyParser('json', { limit: '1mb' })
|
||||
|
||||
// Public, dependency-free educational pages; keep them outside the JSON API prefix.
|
||||
app.useStaticAssets(join(__dirname, '..', 'public', 'learn'), {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsOptional, IsUUID } from 'class-validator'
|
||||
import { IsBoolean, IsOptional, IsUUID } from 'class-validator'
|
||||
|
||||
export class CreateOrderDto {
|
||||
@IsUUID()
|
||||
@@ -7,4 +7,8 @@ export class CreateOrderDto {
|
||||
@IsUUID()
|
||||
@IsOptional()
|
||||
inviterId?: string
|
||||
|
||||
@IsBoolean()
|
||||
@IsOptional()
|
||||
contractAccepted?: boolean
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export class PaymentController {
|
||||
@CurrentUser('sub') userId: string,
|
||||
@Body(new ValidationPipe({ whitelist: true })) dto: CreateOrderDto,
|
||||
) {
|
||||
return this.paymentService.createOrder(userId, dto.cardTypeId, dto.inviterId)
|
||||
return this.paymentService.createOrder(userId, dto.cardTypeId, dto.inviterId, dto.contractAccepted)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,9 +6,10 @@ import { WechatPayService } from './wechat-pay.service'
|
||||
import { InviteModule } from '../invite/invite.module'
|
||||
import { MembershipModule } from '../membership/membership.module'
|
||||
import { BodyPortraitModule } from '../body-portrait/body-portrait.module'
|
||||
import { ContractModule } from '../contract/contract.module'
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule, InviteModule, MembershipModule, BodyPortraitModule],
|
||||
imports: [PrismaModule, InviteModule, MembershipModule, BodyPortraitModule, ContractModule],
|
||||
controllers: [PaymentController],
|
||||
providers: [PaymentService, WechatPayService],
|
||||
exports: [PaymentService, WechatPayService],
|
||||
|
||||
@@ -6,12 +6,13 @@ import {
|
||||
Optional,
|
||||
} from '@nestjs/common'
|
||||
import { CardType, Order } from '@prisma/client'
|
||||
import { OrderStatus } from '@mp-pilates/shared'
|
||||
import { OrderStatus, requiresMembershipContract } 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'
|
||||
import { BodyPortraitLifecycleService } from '../body-portrait/body-portrait-lifecycle.service'
|
||||
import { ContractService } from '../contract/contract.service'
|
||||
|
||||
export interface CreateOrderResult {
|
||||
order: Order
|
||||
@@ -35,11 +36,12 @@ export class PaymentService {
|
||||
private readonly inviteService: InviteService,
|
||||
private readonly membershipService: MembershipService,
|
||||
@Optional() private readonly portraitLifecycle?: BodyPortraitLifecycleService,
|
||||
@Optional() private readonly contractService?: ContractService,
|
||||
) {}
|
||||
|
||||
// ─── User: create order ────────────────────────────────────────────────────
|
||||
|
||||
async createOrder(userId: string, cardTypeId: string, inviterId?: string): Promise<CreateOrderResult> {
|
||||
async createOrder(userId: string, cardTypeId: string, inviterId?: string, contractAccepted = false): Promise<CreateOrderResult> {
|
||||
const cardType = await this.prisma.cardType.findUnique({ where: { id: cardTypeId } })
|
||||
|
||||
if (!cardType) {
|
||||
@@ -71,6 +73,7 @@ export class PaymentService {
|
||||
inviteInviterId: referral?.inviterId,
|
||||
purchasedCategory: cardType.type,
|
||||
status: OrderStatus.PENDING,
|
||||
contractAcceptedAt: contractAccepted && requiresMembershipContract(cardType) ? new Date() : null,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -150,6 +153,7 @@ export class PaymentService {
|
||||
membershipId: membership.id,
|
||||
},
|
||||
})
|
||||
await this.contractService?.issueForPaidOrder(tx, { order: existingOrder, cardType, membership, paidAt: now })
|
||||
await this.inviteService.rewardPaidOrder(tx, existingOrder, now)
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user