Files
sb-backend/src/main/java/com/sb/web/account/service/RecurringService.java
T
ByungCheolandClaude Opus 4.8 7de643022b feat: 고정지출 생성분을 '확인 필요(pending)'로 — 실제 처리 여부 확인
- RecurringService.buildEntry: pending=true로 생성 → 가계부 '확인 필요' 목록에 노출,
  사용자가 실제 결제·이체 확인 후 확정(빈 body confirm은 분류/계좌 유지)
- 메모 없으면 제목으로 채워 확인 목록에서 식별 쉽게

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 17:13:14 +09:00

183 lines
7.4 KiB
Java

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<RecurringResponse> 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();
}
}