diff --git a/ruoyi-modules/ruoyi-edu/src/main/java/org/dromara/edu/application/service/contract/impl/EduPaymentPlanServiceImpl.java b/ruoyi-modules/ruoyi-edu/src/main/java/org/dromara/edu/application/service/contract/impl/EduPaymentPlanServiceImpl.java
new file mode 100644
index 000000000..6476dda5a
--- /dev/null
+++ b/ruoyi-modules/ruoyi-edu/src/main/java/org/dromara/edu/application/service/contract/impl/EduPaymentPlanServiceImpl.java
@@ -0,0 +1,212 @@
+package org.dromara.edu.application.service.contract.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import lombok.RequiredArgsConstructor;
+import org.dromara.common.satoken.utils.LoginHelper;
+import org.dromara.edu.application.dto.EduPaymentPlanBo;
+import org.dromara.edu.application.dto.PaymentReceiveBo;
+import org.dromara.edu.application.service.contract.IEduPaymentPlanService;
+import org.dromara.edu.common.constant.EduConstants;
+import org.dromara.edu.common.exception.EduBusinessException;
+import org.dromara.edu.domain.model.contract.EduContract;
+import org.dromara.edu.domain.model.contract.EduContractLog;
+import org.dromara.edu.domain.model.contract.EduContractPaymentPlan;
+import org.dromara.edu.infrastructure.persistence.mapper.EduContractLogMapper;
+import org.dromara.edu.infrastructure.persistence.mapper.EduContractMapper;
+import org.dromara.edu.infrastructure.persistence.mapper.EduContractPaymentPlanMapper;
+import org.dromara.edu.interfaces.vo.EduContractPaymentPlanVo;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.ZoneId;
+import java.time.temporal.ChronoUnit;
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 合同缴费计划 业务实现
+ *
分期合同的应收节点生成 + 收款联动合同账本 + 退费/作废联动作废
+ *
+ * @author FIGS
+ */
+@RequiredArgsConstructor
+@Service
+public class EduPaymentPlanServiceImpl implements IEduPaymentPlanService {
+
+ private final EduContractPaymentPlanMapper paymentPlanMapper;
+ private final EduContractMapper contractMapper;
+ private final EduContractLogMapper contractLogMapper;
+
+ /**
+ * 生成缴费计划(幂等: 先删后建)
+ * 金额: 首期吸收尾差, 保证各期之和精确等于 totalAmount;
+ * 日期: 首期=签约日, 第N期=签约日+(N-1)*30天
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void generatePaymentPlan(Long contractId, int installmentCount) {
+ EduContract contract = contractMapper.selectById(contractId);
+ if (contract == null) {
+ throw new EduBusinessException("CONTRACT_NOT_FOUND", "合同不存在");
+ }
+ if (installmentCount != 2 && installmentCount != 3) {
+ throw new EduBusinessException("INSTALLMENT_COUNT_INVALID", "分期数必须为 2 或 3");
+ }
+ // 幂等: 先删后建
+ paymentPlanMapper.delete(new LambdaQueryWrapper()
+ .eq(EduContractPaymentPlan::getContractId, contractId));
+
+ BigDecimal totalAmount = nz(contract.getTotalAmount());
+ // base = totalAmount / N, 第2~N期取 base, 第1期吸收尾差
+ BigDecimal base = totalAmount.divide(BigDecimal.valueOf(installmentCount), 2, RoundingMode.HALF_UP);
+ BigDecimal firstAmount = totalAmount.subtract(base.multiply(BigDecimal.valueOf(installmentCount - 1)));
+
+ LocalDate signLocal = toLocalDate(contract.getSignDate());
+ for (int i = 1; i <= installmentCount; i++) {
+ EduContractPaymentPlan plan = new EduContractPaymentPlan();
+ plan.setContractId(contractId);
+ plan.setInstallmentNo(i);
+ plan.setPlanAmount(i == 1 ? firstAmount : base);
+ plan.setPlanDate(toDate(signLocal.plus(30L * (i - 1), ChronoUnit.DAYS)));
+ plan.setPaidAmount(BigDecimal.ZERO);
+ plan.setStatus(EduConstants.PaymentPlanStatus.PENDING);
+ paymentPlanMapper.insert(plan);
+ }
+ }
+
+ /**
+ * 查询合同的计划列表(按期次升序)
+ */
+ @Override
+ public List selectPlanList(Long contractId) {
+ return paymentPlanMapper.selectVoList(new LambdaQueryWrapper()
+ .eq(EduContractPaymentPlan::getContractId, contractId)
+ .orderByAsc(EduContractPaymentPlan::getInstallmentNo));
+ }
+
+ /**
+ * 修改单期计划(仅待收态可改应收金额/应付日期)
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public int updatePlan(EduPaymentPlanBo bo) {
+ EduContractPaymentPlan exist = paymentPlanMapper.selectById(bo.getId());
+ if (exist == null) {
+ throw new EduBusinessException("PLAN_NOT_FOUND", "缴费计划不存在");
+ }
+ if (!EduConstants.PaymentPlanStatus.PENDING.equals(exist.getStatus())) {
+ throw new EduBusinessException("PLAN_STATUS_NOT_EDITABLE", "仅待收态计划可修改");
+ }
+ EduContractPaymentPlan update = new EduContractPaymentPlan();
+ update.setId(bo.getId());
+ if (bo.getPlanAmount() != null) {
+ update.setPlanAmount(bo.getPlanAmount());
+ }
+ if (bo.getPlanDate() != null) {
+ update.setPlanDate(bo.getPlanDate());
+ }
+ update.setRemark(bo.getRemark());
+ return paymentPlanMapper.updateById(update);
+ }
+
+ /**
+ * 收款确认: 待收 → 已收, 联动合同账本
+ * 1. 置该期已收, 记 paidAmount/paidDate/paymentMethod;
+ * 2. 重算合同 paidAmount = SUM(已收期 paidAmount);
+ * 3. 重算 paymentStatus: 全额=已支付(2), 否则部分支付(1);
+ * 4. 写合同流水(action=RECEIVE_PAYMENT).
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public int receivePayment(PaymentReceiveBo bo) {
+ EduContractPaymentPlan plan = paymentPlanMapper.selectById(bo.getId());
+ if (plan == null) {
+ throw new EduBusinessException("PLAN_NOT_FOUND", "缴费计划不存在");
+ }
+ if (!EduConstants.PaymentPlanStatus.PENDING.equals(plan.getStatus())) {
+ throw new EduBusinessException("PLAN_STATUS_NOT_RECEIVABLE", "仅待收态计划可收款");
+ }
+ // 1. 更新该期
+ EduContractPaymentPlan update = new EduContractPaymentPlan();
+ update.setId(plan.getId());
+ update.setStatus(EduConstants.PaymentPlanStatus.RECEIVED);
+ update.setPaidAmount(bo.getPaidAmount());
+ update.setPaymentMethod(bo.getPaymentMethod());
+ update.setPaidDate(bo.getPaidDate() != null ? bo.getPaidDate() : new Date());
+ update.setRemark(bo.getRemark());
+ int rows = paymentPlanMapper.updateById(update);
+
+ // 2. 重算合同实收: SUM(已收期 paidAmount), 本期刚置为已收, 库里已为最新值
+ List all = paymentPlanMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(EduContractPaymentPlan::getContractId, plan.getContractId()));
+ BigDecimal receivedSum = all.stream()
+ .filter(p -> EduConstants.PaymentPlanStatus.RECEIVED.equals(p.getStatus()))
+ .map(p -> nz(p.getPaidAmount()))
+ .reduce(BigDecimal.ZERO, BigDecimal::add);
+
+ // 3. 重算 paymentStatus 并回写合同
+ EduContract contract = contractMapper.selectById(plan.getContractId());
+ BigDecimal total = nz(contract.getTotalAmount());
+ String paymentStatus = receivedSum.compareTo(total) >= 0 ? "2" : "1";
+ EduContract contractUpd = new EduContract();
+ contractUpd.setId(contract.getId());
+ contractUpd.setPaidAmount(receivedSum);
+ contractUpd.setPaymentStatus(paymentStatus);
+ contractMapper.updateById(contractUpd);
+
+ // 4. 写合同流水
+ EduContractLog log = new EduContractLog();
+ log.setContractId(plan.getContractId());
+ log.setAction(EduConstants.ContractAction.RECEIVE_PAYMENT);
+ log.setAmount(bo.getPaidAmount());
+ log.setOperatorId(LoginHelper.getUserId());
+ log.setOperatorName(LoginHelper.getUsername());
+ log.setReason("第" + plan.getInstallmentNo() + "期收款, 金额 " + bo.getPaidAmount()
+ + (bo.getPaymentMethod() != null ? ", 方式 " + bo.getPaymentMethod() : ""));
+ log.setLogTime(new Date());
+ contractLogMapper.insert(log);
+ return rows;
+ }
+
+ /**
+ * 批量作废未收期次(合同退费/作废联动)
+ * 仅把"待收"期置"已作废", "已收"期保留作为历史收款记录
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public int voidPendingPlans(Long contractId) {
+ List pending = paymentPlanMapper.selectList(
+ new LambdaQueryWrapper()
+ .eq(EduContractPaymentPlan::getContractId, contractId)
+ .eq(EduContractPaymentPlan::getStatus, EduConstants.PaymentPlanStatus.PENDING));
+ int rows = 0;
+ for (EduContractPaymentPlan p : pending) {
+ EduContractPaymentPlan upd = new EduContractPaymentPlan();
+ upd.setId(p.getId());
+ upd.setStatus(EduConstants.PaymentPlanStatus.VOIDED);
+ rows += paymentPlanMapper.updateById(upd);
+ }
+ return rows;
+ }
+
+ // ------------------------------------------------------------------
+ // 私有辅助
+ // ------------------------------------------------------------------
+
+ private BigDecimal nz(BigDecimal v) {
+ return v == null ? BigDecimal.ZERO : v;
+ }
+
+ private LocalDate toLocalDate(Date date) {
+ return date == null ? LocalDate.now() : date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate();
+ }
+
+ private Date toDate(LocalDate localDate) {
+ return Date.from(localDate.atStartOfDay(ZoneId.systemDefault()).toInstant());
+ }
+
+}