package com.sb.web.account.service; import com.sb.web.account.domain.AccountEntry; import com.sb.web.account.domain.Recurring; import com.sb.web.account.dto.RecurringRequest; import com.sb.web.account.dto.RecurringResponse; import com.sb.web.account.mapper.AccountEntryMapper; import com.sb.web.account.mapper.RecurringMapper; import com.sb.web.common.exception.ApiException; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.time.YearMonth; import java.util.List; /** * 정기/반복 거래 서비스. CRUD + 밀린 회차 자동 생성(중복 없이). */ @Service @RequiredArgsConstructor public class RecurringService { private final RecurringMapper recurringMapper; private final AccountEntryMapper entryMapper; public List list(Long memberId) { return recurringMapper.findByMember(memberId).stream().map(this::toResponse).toList(); } @Transactional public RecurringResponse create(RecurringRequest req, Long memberId) { Recurring r = toEntity(req); r.setMemberId(memberId); recurringMapper.insert(r); return toResponse(recurringMapper.findByIdAndMember(r.getId(), memberId)); } @Transactional public RecurringResponse update(Long id, RecurringRequest req, Long memberId) { mustFind(id, memberId); Recurring r = toEntity(req); r.setId(id); r.setMemberId(memberId); recurringMapper.update(r); return toResponse(recurringMapper.findByIdAndMember(id, memberId)); } @Transactional public void delete(Long id, Long memberId) { mustFind(id, memberId); recurringMapper.delete(id, memberId); } /** 활성 규칙의 밀린 회차를 오늘까지 생성 (중복 없음). 생성 건수 반환 */ @Transactional public int run(Long memberId) { LocalDate today = LocalDate.now(); int count = 0; for (Recurring r : recurringMapper.findActiveByMember(memberId)) { LocalDate cursor = r.getLastRunDate() != null ? r.getLastRunDate() : r.getStartDate().minusDays(1); LocalDate next = nextOccurrence(r, cursor); LocalDate generatedUpTo = null; int guard = 0; while (next != null && !next.isAfter(today) && (r.getEndDate() == null || !next.isAfter(r.getEndDate())) && guard++ < 1000) { entryMapper.insert(buildEntry(r, next, memberId)); generatedUpTo = next; count++; next = nextOccurrence(r, next); } if (generatedUpTo != null) { recurringMapper.updateLastRun(r.getId(), generatedUpTo); } } return count; } /* ===================== 내부 ===================== */ private AccountEntry buildEntry(Recurring r, LocalDate date, Long memberId) { boolean transfer = "TRANSFER".equals(r.getType()); // 메모가 없으면 제목으로 채워 '확인 필요' 목록에서 알아보기 쉽게 한다. String memo = (r.getMemo() != null && !r.getMemo().isBlank()) ? r.getMemo() : r.getTitle(); return AccountEntry.builder() .memberId(memberId) .entryDate(date) .type(r.getType()) .category(transfer ? null : r.getCategory()) .amount(r.getAmount()) .memo(memo) .walletId(r.getWalletId()) .toWalletId(transfer ? r.getToWalletId() : null) // 실제 결제/이체가 일어났는지 사용자가 확인하도록 '확인 필요'로 생성 .pending(true) .build(); } /** from 이후 첫 발생일 (없으면 null) */ private LocalDate nextOccurrence(Recurring r, LocalDate from) { return switch (r.getFrequency()) { case "DAILY" -> from.plusDays(1); case "WEEKLY" -> { int dow = r.getDayOfWeek() != null ? r.getDayOfWeek() : 1; LocalDate d = from.plusDays(1); while (d.getDayOfWeek().getValue() != dow) d = d.plusDays(1); yield d; } case "MONTHLY" -> { int dom = r.getDayOfMonth() != null ? r.getDayOfMonth() : 1; YearMonth ym = YearMonth.from(from); LocalDate c = clamp(ym, dom); if (!c.isAfter(from)) c = clamp(ym.plusMonths(1), dom); yield c; } case "YEARLY" -> { int moy = r.getMonthOfYear() != null ? r.getMonthOfYear() : 1; int dom = r.getDayOfMonth() != null ? r.getDayOfMonth() : 1; LocalDate c = clamp(YearMonth.of(from.getYear(), moy), dom); if (!c.isAfter(from)) c = clamp(YearMonth.of(from.getYear() + 1, moy), dom); yield c; } default -> null; }; } private LocalDate clamp(YearMonth ym, int dom) { return ym.atDay(Math.min(dom, ym.lengthOfMonth())); } private Recurring toEntity(RecurringRequest req) { return Recurring.builder() .title(req.getTitle().trim()) .type(req.getType()) .amount(req.getAmount()) .category("TRANSFER".equals(req.getType()) ? null : blankToNull(req.getCategory())) .memo(blankToNull(req.getMemo())) .walletId(req.getWalletId()) .toWalletId("TRANSFER".equals(req.getType()) ? req.getToWalletId() : null) .frequency(req.getFrequency()) .dayOfMonth(req.getDayOfMonth()) .dayOfWeek(req.getDayOfWeek()) .monthOfYear(req.getMonthOfYear()) .startDate(req.getStartDate()) .endDate(req.getEndDate()) .active(req.isActive()) .build(); } private RecurringResponse toResponse(Recurring r) { LocalDate cursor = r.getLastRunDate() != null ? r.getLastRunDate() : r.getStartDate().minusDays(1); LocalDate next = nextOccurrence(r, cursor); if (next != null && r.getEndDate() != null && next.isAfter(r.getEndDate())) { next = null; } return RecurringResponse.builder() .id(r.getId()).title(r.getTitle()).type(r.getType()).amount(r.getAmount()) .category(r.getCategory()).memo(r.getMemo()) .walletId(r.getWalletId()).walletName(r.getWalletName()) .toWalletId(r.getToWalletId()).toWalletName(r.getToWalletName()) .frequency(r.getFrequency()).dayOfMonth(r.getDayOfMonth()) .dayOfWeek(r.getDayOfWeek()).monthOfYear(r.getMonthOfYear()) .startDate(r.getStartDate()).endDate(r.getEndDate()).lastRunDate(r.getLastRunDate()) .nextDate(next).active(Boolean.TRUE.equals(r.getActive())) .build(); } private Recurring mustFind(Long id, Long memberId) { Recurring r = recurringMapper.findByIdAndMember(id, memberId); if (r == null) { throw new ApiException(HttpStatus.NOT_FOUND, "정기 거래를 찾을 수 없습니다."); } return r; } private String blankToNull(String s) { return (s == null || s.isBlank()) ? null : s.trim(); } }