English

DIY Rental Platform — Slice 3: Offers, Tenancy Agreements, and E-Signature

Slice 3 is where the Malaysian rental platform stops being a noticeboard and becomes the place the deal closes: a tenant with a confirmed viewing offers terms, the landlord counters or accepts, and the platform issues a lawyer-reviewed tenancy agreement that both parties sign inside the app. A grilling session on 2026-08-26 settled 23 open questions and produced three ADRs — documents frozen and content-addressed at issue, click-to-sign under the Electronic Commerce Act 2006, and one blank-filling template under the Legal Profession Act 1976 s.37.

  • Predecessor Slice 2 — Viewings, Messaging, and Reminders (2026-08-24-rental-platform-slice2-viewings-design)
  • ADRs 0002 · 0006
  • New ADRs 0058 · 0059 · 0060
  • Status Approved

View source markdown ↗ generated by claude-opus-5 · diagrams mermaid

第三阶段是租赁平台从「告示板」变成「真正成交场所」的分水岭:看过房的租客提出条件,房东还价或接受, 平台随即生成一份经律师审阅的租约,双方在应用内完成签署。普通案件不需要中介,不需要律师, 也没有任何资金流经平台。

概览

  • 阶段 第三阶段 — 报价、租约、电子签署
  • 状态 设计已批准,尚未实现
  • 前置条件 第二阶段(看房)必须先合并
  • 解锁 第四阶段 — 租金收缴追踪
  • 司法辖区 马来西亚 — ECA 2006、LPA 1976 s.37、PDPA 2010
  • 新增 ADR 0058、0059、0060
依赖
第一阶段(房源列表,已合并于 58c47ceb)与第二阶段(看房,仅完成设计)
新增表
rental_offersrental_tenancy_agreementsrental_agreement_signatures
修改的表
rental_contacts 新增 full_namenricaddress_line
新增依赖库
无 —— github.com/phpdave11/gofpdf 已被发票模块使用
新增角色令牌
无 —— 仅使用行级校验,与第一、第二阶段一致
术语表
CONTEXT.md — Offer、Tenancy Agreement、Issue、Frozen Document、Void、Special Conditions、NRIC

成功标准

  • 已确认看房的租客可以提出条件,房东可以还价或接受。
  • 房东接受后,平台生成一份用真实数据填充的租约 PDF。
  • 双方在平台内签署,签署后的 PDF 双方均可下载。
  • 签署文件具备防篡改证据:每一次签署都记录所签文件的哈希值。
  • 房源以 rented 状态离开公开市场页面。
  • 没有任何资金流经平台。

范围与非目标

范围内

第三阶段
  • 基于已确认看房请求的报价与还价
  • 当事人资料:身份证姓名、身份证号码、通信地址
  • 由一份固定的、经律师审阅的模板生成租约
  • 双方点击式签署,并留存证据链
  • 冻结文件与签署文件,仅限双方下载
  • 新增终结状态 rented

范围外

经决策排除
  • 印花税与 LHDN 盖印
  • 任何形式的资金 —— 押金、支付网关、第三方托管
  • 租客背景审查与信用调查
  • 条款选择器、续约、提前终止
  • 交房物品清单
  • 手写签名图像;DSA 1997 认证签名
  • 第三方电子签署服务商

已知缺口

已接受
  • 租约未盖印,盖印前不可在法庭举证
  • 身份证号码由用户自行申报,未经核验
  • 不支持编辑 —— 变更即作废并重新签发
  • 报价没有有效期,不会自动拒绝
  • 每方仅一位签署人;联名租客延后处理
  • 账号删除后记录仍会保留
每项非目标被排除的原因
盖印
租约在双方之间有效,但未盖印前不可在法庭举证。自动化意味着接入 LHDN 并走它自己的审批流程 —— 那是一个项目,不是一个功能。平台会在界面上明确说明这一点。
身份核验
身份证号码由用户自行申报,租客可以随便输入。第一阶段已将背景审查排除在外,而在当前业务量下核验带来的摩擦过大。一旦出现欺诈,这是第一个要补上的功能。
编辑已签发的租约
不支持编辑。变更意味着作废并重新签发一份 —— 纸质流程也是这么做的。
报价有效期
没有计时器,也不会自动拒绝。「每次看房只有一份有效报价」的规则已经限制了混乱程度,而过期报价首先是产品问题,其次才是数据问题。
未看房即报价
报价必须基于一次已确认的看房请求 —— 这直接复用第二阶段的判定条件,而不是另立规则。
联名租客
每方仅一位签署人。签署记录本来就存放在以 (agreement_id, signer_user_id) 为键、带 signer_role 的独立表中,而完成判定是从租约行推导出「应签署人集合」,而不是数到二 —— 所以日后增加联名租客只需改这段推导,不需要数据迁移。
数据保留与账号删除
租约、签署记录与两份 PDF 都是法律记录:予以保留,且不会随用户删除账号而删除。只有 rental_contacts 是可删除的个人资料行。真正的数据保留策略应归属于第一个处理全平台账号删除的阶段。

关键决策

在签发时冻结文件,而不是按需渲染

房东接受报价时,PDF 只渲染一次、存储并计算哈希。该对象永不改变;双方签署的正是这个文件。ADR-0058。

双方在不同时间签署是常态。如果在第一次与第二次签署之间模板或渲染代码发生变化,那么两位当事人签的就是不同的文件, 「我到底签了什么?」这个问题将无法可靠回答。冻结正是审计链条价值的来源;没有它,链条记录的是针对一个移动目标的签名。

已否决:从 terms 按需渲染(更省事,但渲染器一变,保证立刻失效);按需渲染并维护模板注册表 (修复了正确性问题,代价是要维护注册表,以及渲染代码必须与自己的历史缺陷保持兼容)。

点击式签署,不用手写签名,也不用 PKI

每方输入身份证姓名与号码,勾选同意后提交。平台记录 user_id、时间戳、IP、User-Agent,以及其所见文件的 SHA-256。ADR-0059。

这在《2006 年电子商务法》下是有效的,该法并未将租约排除在适用范围之外。让证据链有说服力的不是签名的外观, 而是能够证明「哪份文件、由哪个账号、在什么时间」被签署。

已否决:手写签名图像(看着熟悉,但没有额外法律效力,前端成本却是真的);DSA 1997 认证签名 (证据力更强,但每一方都要持有 CA 证书,这会让注册流程直接夭折);DocuSign、Zoho Sign 之类的第三方服务 (开发快,但按每份租约收费,并且会把文件流程 —— 连同双方的身份证号码 —— 移出平台)。

一份固定模板,外加自由填写的特别条款

平台只在一份经律师审阅过的表格中填空,不起草任何定制条款。ADR-0060。

根据《1976 年法律专业法》第 37 条,在标准表格中填空与起草在性质上有实质区别,而前者是更安全的一侧。 条款选择器看起来像功能,读起来却像起草:一旦平台代客户在多种法律措辞之间做选择,它所做之事的性质就变了。

暂时否决:基于律师审阅条款库的条款选择器 —— 等 special_conditions 显示出房东反复输入什么内容之后, 这是很自然的后续补充。这个自由文本字段就是收集证据的研究工具。

报价挂在看房请求之下

rental_offers.request_id 是一个外键,并带部分唯一索引 —— 仅在 status IN ('pending','countered') 时唯一 —— 且该请求必须满足 confirmed_time IS NOT NULL

这一次买到三样东西:rental_messages 对话可以直接延续,因此不存在第二个线程概念; 双方已经互相持有联系方式;而「没看房就不能报价」这条规则无需另写代码即被强制执行。

已否决:request_id 完全唯一。那将意味着每次看房只能报价一次 —— 房东若嫌 RM 1,600 太低而点了拒绝,租客就被永久挡住,无法回头出价 RM 1,750; 唯一的出路是等第二阶段的 24 小时清扫、为已看过的房子再约一次看房,并请房东再确认一遍。

架构

internal/modules/rental/ 中的模块是真实代码,不是计划。第一阶段已交付六边形分层、 module.go 中的函数选项式装配、fn_audit_trail() 触发器注册(ADR-0002), 以及一个刻意不提供签名 URLstorage.Storage。 第三阶段只是继续填充同样的形状,在装配层不发明任何新东西。

文件
domain/entity/offer.goagreement.go —— 状态转移函数与校验,纯函数
application/port/ports.go 中新增 AgreementStoreAgreementRendererMailer 新增九个方法
application/usecase/offer.goagreement.gosigning.go
adapter/inbound/http/offer_handler.goagreement_handler.go
adapter/outbound/pdf/renderer.go —— 使用 gofpdf,对齐发票模块的 PDF 适配器

HTTP 接口

POST   /api/rental/viewing-requests/:id/offers     tenant creates an offer
GET    /api/rental/offers/:id                      either party
POST   /api/rental/offers/:id/counter              landlord
POST   /api/rental/offers/:id/reoffer              tenant
POST   /api/rental/offers/:id/accept               landlord → issues the agreement
POST   /api/rental/offers/:id/decline              landlord
POST   /api/rental/offers/:id/withdraw             tenant
GET    /api/rental/agreements/:id                  either party, NRIC masked
GET    /api/rental/agreements/:id/document         frozen PDF, streamed
GET    /api/rental/agreements/:id/signed-document  composite, lazily generated
POST   /api/rental/agreements/:id/sign             either party
POST   /api/rental/agreements/:id/void             either party, reason required
GET    /api/rental/me/agreements                   list, for the panel's landing view

counterreoffer 是两条独立路由而不是一个 PATCH, 因为它们的授权规则与状态转移都不同;合并它们只会产生一个「根据调用者分支」的处理函数。 报价始终通过其看房请求访问,本身没有列表接口, 这让「报价挂在看房请求之下」不仅体现在数据结构里,也体现在 API 形态上。

邮件

Mailer 上共九个方法。每一个都携带指向授权 Go 处理函数的链接,绝不附带 PDF 附件。

事件收件人
报价已创建房东
还价对方
还价后再报价房东
报价被拒绝租客,附带 decline_reason
租约已签发双方
已记录第一份签署尚未签署的一方
租约已完成双方
租约已作废双方,注明由谁作废及原因
落选报价被自动拒绝每一位落选租客

撤回报价不发送任何邮件 —— 线程里已经显示了,而且房东并没有损失什么。

数据模型

三张新表,一张现有表新增三列。全部写在 migrator_rental.go 中。

rental_offers

idrequest_idlisting_idtenant_user_idlandlord_user_idmonthly_rentstart_dateterm_monthssecurity_deposit_monthsutility_deposit_monthsadvance_rent_monthsspecial_conditionsstatusdecline_reasonlast_actor_user_idcreated_atupdated_at

CREATE UNIQUE INDEX idx_rental_offers_live
    ON rental_offers (request_id)
 WHERE status IN ('pending', 'countered');
landlord_user_id
反规范化存储,使行级授权检查无需 join。第二阶段的 rental_viewing_requests 对同一个人使用同一个列名;第一阶段已上线的 rental_properties.owner_user_id 保留原名,因为它记录的是谁拥有该房产 —— 这与谁是某次租约中的房东是不同的关系。
special_conditions
自由文本,与其他条件一样属于可协商项。还价的一方可以修改它,每次修改都会落入消息线程。
decline_reason
可为空:房东拒绝时可选填;完成时的清扫自动拒绝落选报价时,会写入一段服务端格式化的文字。刻意不设 withdraw_reason
押金默认值
马来西亚惯例 —— 2 个月押金、0.5 个月水电押金、1 个月预付租金 —— 与租金一样可协商。绝不存储马币金额:它们是 monthly_rent × months,在渲染时计算。
校验
start_date 不得早于今天;term_months 介于 1 至 36 之间。

rental_tenancy_agreements

idoffer_id(唯一)、listing_idproperty_idlandlord_user_idtenant_user_idtemplate_versionterms(jsonb)、document_keydocument_sha256signed_document_keystatusissued_atcompleted_atvoided_atvoid_reasoncreated_atupdated_at

CREATE UNIQUE INDEX idx_rental_agreements_live_listing
    ON rental_tenancy_agreements (listing_id)
 WHERE status <> 'voided';

document_key 指向冻结的未签署 PDF,其值就是该文件字节的 SHA-256。 signed_document_key 指向最终的合成文件,同样采用内容寻址, 在有人第一次下载之前为 null。

rental_agreement_signatures

idagreement_idsigner_user_idsigner_rolelandlordtenant)、full_namenricdocument_sha256signed_atipuser_agent

  • (agreement_id, signer_user_id) 上唯一 —— 这个约束就是防重复签署的全部防线,而不是应用逻辑。
  • document_sha256 按每次签署存储,因为它记录的是签署人实际看到的哈希。若两者出现不一致,说明文件被动过,这就从「无法追究」变成了「可被检测」。
  • full_namenric 是签署人在签署那一刻所声明的内容 —— 这与其个人资料当前的值是不同的事实。full_name 存储的是原样输入的内容,而不是用于比对的归一化形式。
  • ipuser_agent 只存在于此处。合成 PDF 不会打印它们。

rental_contacts 与身份证号码处理

新增三列:full_namenricaddress_linefull_name身份证上印刷的姓名,未必等于 users.name。 这是一个真实的列,而不是 Better Auth 数据的副本;何况 ADR-0006 本来就禁止回写 users

身份证号码属于《2010 年个人资料保护法》下的敏感个人资料。它以明文存储 —— 整个数据库只有一个 Postgres,租赁模块里也没有别的东西加密 —— 但每一个 API 响应都会将其遮蔽为 ******-**-**34。出生日期同样被遮蔽:身份证号码的结构是 YYMMDD-PB-###G, 保留前六位就等于公开完整出生日期,而出生日期本身也是个人资料。最后两位已足以让本人确认号码是自己的。

完整值只出现在生成的 PDF 内部,而 PDF 绝不会作为邮件附件发送。 一边遮蔽 API、一边把同一个号码寄进两个邮箱,等于把整套防护措施全部作废。

审计触发器

rental_offersrental_tenancy_agreements 会挂上 fn_audit_trail() 触发器,与现有的 trg_audit_rental_properties 注册在一起(ADR-0002)。rental_agreement_signatures 不挂: 那些行本来就是只追加的,触发器只会重复一遍。

状态机

报价

报价状态机 —— 刻意与第二阶段的请求状态机同形,只去掉不适用的部分。 countered 会就地覆盖条件, status 说明当前摆在桌面上的是哪一方的条件。
报价状态机 —— 刻意与第二阶段的请求状态机同形,只去掉不适用的部分。countered 会就地覆盖条件,status 说明当前摆在桌面上的是哪一方的条件。

每一次还价与再报价都会插入一行携带新条件的 rental_messages, 因此即使列上只保存当前立场,线程仍保留完整的谈判过程。

租约

租约状态机 —— 只有三个状态。「谁签了」由签署表回答,而不是靠增加枚举值。作废是对称的:在租约完成之前,任一方都可以退出。
租约状态机 —— 只有三个状态。「谁签了」由签署表回答,而不是靠增加枚举值。作废是对称的:在租约完成之前,任一方都可以退出。

房源 —— 对第一阶段的唯一改动

房源状态机新增终结状态 rented ,在租约完成时进入。 EventComplete 可从三个来源触发,而不只是 live 。
房源状态机新增终结状态 rented,在租约完成时进入。EventComplete 可从三个来源触发,而不只是 live
为什么用 rented 而不复用 withdrawn,以及为什么不加 reserved 状态

withdrawn 在技术上可行且零成本,因为公开市场页面按 live 过滤,两个值都会被排除。 否决它的理由是:房东自己的面板届时会把一处成功租出去的房产显示为「已撤回」。 多一个枚举值换来的是诚实的数据。

在等待签署期间房源也保持 live —— 不新增 reserved 状态。 其他租客仍可以为一处已经八九不离十的房产预约看房和报价,而房东如果尝试接受,只会拿到 409; 防重复出租的防线已经覆盖了正确性。第二个枚举值买到的是一个可逆状态和两条额外转移, 只为让面板上的标签更诚实 —— 而面板完全可以免费地写上「租约签署中」。

公开市场页面不需要任何改动:它本来就对任何非 live 的房源返回 404 并显示「该房源已不可租」页面, 因此 rented 会自动从公开页面消失。日后重新出租也不受影响 —— 第一阶段本来就把「每次租期结束」视为重新挂牌,而 rented 对 「每处房产至多一条非终结房源」规则而言属于终结状态,所以业主是创建一条新房源,而不是复用旧的。

授权

动作校验
创建或撤回报价已登录,会话用户是某个满足 confirmed_time IS NOT NULL 的请求上的 tenant_user_id
接受、还价或拒绝报价已登录,会话用户是 landlord_user_id
读取报价或租约、下载任一 PDF已登录,会话用户是双方之一
签署以上条件,该用户尚无签署记录行
作废任一方,且仅在 awaiting_signatures 期间
管理员无 —— 第三阶段没有管理员接口,与第二阶段一致
公开房源 API不变 —— 第一阶段的黄金测试仍在守护它

不新增角色令牌,不使用 RequireOrgRole,不改动 Better Auth。仅使用行级校验,与第一、第二阶段完全一致。

流程

A —— 租客报价

  1. 租客从一次已确认的看房请求进入提出报价
  2. 若租客尚未填写 full_namenricaddress_line,先用一个简短表单收集。
  3. 表单预填房源的要价与 2 / 0.5 / 1 的押金惯例。特别条款初始为空。
  4. 提交后创建一行状态为 pendingrental_offers
  5. 一行描述条件(包含特别条款)的 rental_messages 进入现有线程。
  6. 向房东发送邮件。

还价与再报价在同一行上重复第 4 至 6 步,由 last_actor_user_id 记录当前是哪一方的条件。

B —— 房东接受,签发租约

流程 B。第 1 至 4 步在任何事务之外执行;第 5 至 6 步是一个短事务。由于存储键就是内容哈希,重试会写入同一个键,孤儿对象因而自愈。
流程 B。第 1 至 4 步在任何事务之外执行;第 5 至 6 步是一个短事务。由于存储键就是内容哈希,重试会写入同一个键,孤儿对象因而自愈。
漂移来源固定为
PDF 创建日期issued_at —— 绝不用 now(),而 gofpdf 默认就盖当前时间
日期格式化固定的 Asia/Kuala_Lumpur,使宿主机的 TZ 无法改变字节
字体提交进仓库的字体文件,绝不使用系统字体路径
签署顺序(合成件)signed_at,并以 signer_role 作为并列时的次序依据 —— Go 的 map 迭代顺序是随机的

C —— 签署

任一方都可以先签;顺序无关紧要。

  1. 下载 PDF

    由一个 Go 处理函数提供,它会校验调用者是双方之一 —— 不存在公开 URL,因为 storage.Storage 根本没有签名 URL 的接口。

  2. 输入、勾选、提交

    签署人输入身份证姓名与号码,并勾选同意。

  3. 与快照比对,然后写入

    用例校验输入值,然后插入一行携带 document_sha256、IP 与 User-Agent 的签署记录。

  4. 唯一索引拒绝第二次尝试

    防线是 (agreement_id, signer_user_id),而不是应用逻辑。

D —— 完成

当每一位应签署人都有记录行时,签署插入操作即完成该租约。 应签署人集合由租约自身的 landlord_user_idtenant_user_id 推导得出 —— 而不是硬编码的「数到二」—— 因此日后加入联名租客只需改这段推导,其他一概不动。 完成的那次插入还会设置 completed_at、清扫落选报价,并在同一事务内把房源移到 rented

签署页会打印每一方的姓名、其所声明的身份证号码、带时区的时间戳,以及文件的 SHA-256。 它不会打印 IP 地址或 User-Agent。这些留在数据库里,若日后发生争议再行提供。 证据无论如何都在;而把 IP 打印进一份双方可能转发给中介或群聊的文件里, 等于把对方的一部分网络身份交出去,却换不到任何法律上的好处。

错误处理

情形行为
对一个从未确认的请求报价403 —— 与联系方式披露所用的判定条件相同
租客尚未填写当事人资料就报价422,并列出缺失字段
同一请求上的第二份有效报价409,响应体中带上现有报价 —— 由部分唯一索引拦截
在终结报价之后于同一请求上再报价允许 —— 作为新行,这正是被拒绝的报价得以挽回的方式
身份证号码格式错误,或前六位不是真实日期写入 rental_contacts 时返回 422
start_date 早于今天,或 term_months 超出 1–36422
房东尚未填写当事人资料就接受422,并列出缺失字段
接受时租客资料缺失(竞态)由签发内部的防御性复查返回 422
对已有未作废租约的房源执行接受409 —— 防重复出租索引,在签发事务内被触发
签发时 PDF 渲染或存储写入失败不写入任何内容;报价不变;502
上传之后签发事务失败回滚;孤儿对象采用内容寻址,因此重试复用同一个键
存在未作废租约时撤回房源409
房源处于 pending_reviewsuspended 时完成成功 —— EventComplete 接受全部三个来源
第二份签署并发到达两次唯一索引拒绝其一;失败者得到 409,而不是重复完成
输入的姓名或身份证号码与快照不符归一化之后返回 422;不写入签署记录行
对已作废或已完成的租约签署409
已存在任何签署时作废却未填原因422
双方在同一时刻下载合成件两边都生成;字节相同、键相同;不报错
下载时合成 PDF 生成失败502;租约仍为已完成状态,下一次下载会重试

测试

状态机
对报价与租约的每一条合法与非法转移做表驱动测试,包括 accepted 的终结规则与对称作废。
报价挽回
拒绝一份报价后,断言同一 request_id 上可以插入新报价;断言第二份有效报价被部分唯一索引拒绝。
黄金 PDF,两个时区
用固定的 terms 快照在两个不同 TZ 值下各渲染一次,断言 SHA-256 相同。只渲染一次的测试会通过,而最可能出问题的地方却查不出来。
合成件确定性
对合成件做同样的测试,其创建日期固定为 completed_at,签署按 signed_at 排序。
并发
同时触发两次签署;断言恰好一次完成、一次房源转移、一个 409。
重复出租
并发接受同一房源上的两份报价;断言恰好签发一份租约,另一位调用者得到 409。
签发原子性
在流程 B 的第 4 步注入存储失败;断言没有租约行且报价未变,再断言重试写入的是同一个存储键。
完成时的房源转移
分别在房源处于 livepending_reviewsuspended 时完成租约;断言三种情况都变为 rented。断言存在未作废租约时 EventWithdraw 返回 409。
签署比对
仅大小写或空格不同的姓名可以成功签署;仅横线不同的身份证号码可以成功签署;真正不同的值被拒绝且不写入任何行。
身份证遮蔽
一个黄金测试断言没有任何接口会返回未遮蔽的身份证号码,包括出生日期的任何一位数字,与第一阶段公开房源 API 的黄金测试同一精神。
无附件
断言每一次邮件调用都携带链接,且不含 PDF 正文。
作废证据
在一份签署之后作废;断言原因为必填、签署记录行仍在,且双方都收到通知。
授权
第三位已登录用户在每一个接口上都被拒绝。

交付顺序

  1. rental_contacts 新列、身份证号码写入时归一化与校验、身份证遮蔽
  2. rental_offers 表、部分唯一索引,以及报价状态机
  3. 报价接口、租客资料卡口、线程消息、特别条款
  4. PDF 渲染器、流程 B 中的确定性固定项,以及双时区黄金测试
  5. 租约签发 —— 先渲染上传,再执行流程 B 中的短事务
  6. 签署、归一化比对、按推导签署人集合完成、落选报价清扫
  7. 房源 rented 状态、来自三个来源的 EventComplete、撤回拦截(改动第一阶段)
  8. 首次下载时生成合成 PDF
  9. 邮件 —— 九个方法,仅链接
  10. 面板界面

风险

风险缓解措施
《1976 年法律专业法》第 37 条 —— 为报酬起草法律文件一份经律师审阅的模板;平台只填空,绝不起草定制条款
身份证号码由用户自行申报且未经核验在当前业务量下接受;一旦出现欺诈,身份核验是第一个要补的功能
租约未盖印,不可在法庭举证在签发时与已完成的租约上都清楚说明,而不是藏进小字条款
身份证号码以明文存储在每个 API 响应中遮蔽并有黄金测试覆盖;完整值只存在于 PDF 中
邮件仍是 log.Printf 桩实现真实传输由第二阶段负责;若其延期,本阶段的每条通知都是静默的
每份租约两个对象接受 —— 冻结对象是法律记录,合成件只是随时可以重新生成的便利产物
同一房产跨两条房源的重复出租未解决。要堵上需要租期结束日期,由第四阶段负责
full_namenric 中的错字在签发时被冻结写入时校验并归一化;签署比对归一化后的值,因此单纯的格式差异绝不会把人锁在门外。真正的错字仍需作废并重新签发
账号删除后租约与身份证号码仍被保留刻意如此 —— 它们是法律记录。全平台的数据保留策略由第一个处理账号删除的阶段负责
渲染器必须保持逐字节确定,否则存储键会碎裂固定四处漂移来源,并由双时区黄金测试断言

这一阶段解锁了什么

第四阶段 —— 租金收缴追踪、催缴阶梯、逾期通知 PDF —— 需要一份当事人明确、租金明确、起始日期明确、 租期明确的租约。一行已完成的 rental_tenancy_agreements 正是这份记录。 这里构建的 PDF 渲染器也正是第四阶段逾期通知所要复用的; 而本阶段留下的「同一房产租期重叠」缺口,将是租期结束日期首先要堵上的问题。