- RecurringService.buildEntry: pending=true로 생성 → 가계부 '확인 필요' 목록에 노출, 사용자가 실제 결제·이체 확인 후 확정(빈 body confirm은 분류/계좌 유지) - 메모 없으면 제목으로 채워 확인 목록에서 식별 쉽게 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
183 lines
7.4 KiB
Java
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();
|
|
}
|
|
}
|