diff --git a/common/src/main/java/com/ntropy/common/client/VirtualSettlementDepositCommandClient.java b/common/src/main/java/com/ntropy/common/client/VirtualSettlementDepositCommandClient.java new file mode 100644 index 00000000..88162e90 --- /dev/null +++ b/common/src/main/java/com/ntropy/common/client/VirtualSettlementDepositCommandClient.java @@ -0,0 +1,11 @@ +package com.ntropy.common.client; + +import com.ntropy.common.dto.account.VirtualSettlementDepositCommand; +import com.ntropy.common.dto.account.VirtualSettlementDepositResult; + +/** NTROPY 가상계좌에 플랫폼 정산 입금 거래를 멱등하게 생성하는 내부 명령 계약. */ +public interface VirtualSettlementDepositCommandClient { + + /** 누적 목표액에서 기존 생성액을 뺀 차액 거래를 만들고, 해당 정산기간의 매칭 가능 여부를 반환한다. */ + VirtualSettlementDepositResult createOrAdjust(VirtualSettlementDepositCommand command); +} diff --git a/common/src/main/java/com/ntropy/common/dto/account/VirtualSettlementDepositCommand.java b/common/src/main/java/com/ntropy/common/dto/account/VirtualSettlementDepositCommand.java new file mode 100644 index 00000000..fa1009b7 --- /dev/null +++ b/common/src/main/java/com/ntropy/common/dto/account/VirtualSettlementDepositCommand.java @@ -0,0 +1,18 @@ +package com.ntropy.common.dto.account; + +import java.time.LocalDate; + +/** + * work-service가 계산한 플랫폼 정산금의 누적 목표액을 NTROPY 가상 수시입출금 계좌에 기록하는 명령. + * 사용자·플랫폼·정산기간은 account-service가 멱등 fingerprint를 만드는 논리 키로 사용한다. + */ +public record VirtualSettlementDepositCommand( + Long userId, + Long platformId, + LocalDate periodStart, + LocalDate periodEnd, + LocalDate depositDate, + Long amount, + String depositName +) { +} diff --git a/common/src/main/java/com/ntropy/common/dto/account/VirtualSettlementDepositResult.java b/common/src/main/java/com/ntropy/common/dto/account/VirtualSettlementDepositResult.java new file mode 100644 index 00000000..7b3e32ab --- /dev/null +++ b/common/src/main/java/com/ntropy/common/dto/account/VirtualSettlementDepositResult.java @@ -0,0 +1,20 @@ +package com.ntropy.common.dto.account; + +/** 가상 정산 입금 명령 결과. available이면 해당 정산기간을 실제 입금 조회로 매칭할 수 있다. */ +public record VirtualSettlementDepositResult( + boolean available, + boolean transactionCreated +) { + + public static VirtualSettlementDepositResult unavailable() { + return new VirtualSettlementDepositResult(false, false); + } + + public static VirtualSettlementDepositResult alreadyAvailable() { + return new VirtualSettlementDepositResult(true, false); + } + + public static VirtualSettlementDepositResult created() { + return new VirtualSettlementDepositResult(true, true); + } +} diff --git a/services/account-service/src/main/java/com/ntropy/account/client/LocalVirtualSettlementDepositCommandClient.java b/services/account-service/src/main/java/com/ntropy/account/client/LocalVirtualSettlementDepositCommandClient.java new file mode 100644 index 00000000..d21fe152 --- /dev/null +++ b/services/account-service/src/main/java/com/ntropy/account/client/LocalVirtualSettlementDepositCommandClient.java @@ -0,0 +1,121 @@ +package com.ntropy.account.client; + +import java.math.BigDecimal; +import java.time.LocalTime; +import java.util.List; + +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import com.ntropy.account.domain.AccountGroup; +import com.ntropy.account.domain.AccountTransactionCategory; +import com.ntropy.account.domain.ConnectionProvider; +import com.ntropy.account.domain.TransactionFingerprint; +import com.ntropy.account.domain.entity.Account; +import com.ntropy.account.domain.entity.AccountTransaction; +import com.ntropy.account.mapper.AccountMapper; +import com.ntropy.account.mapper.VirtualSettlementDepositMapper; +import com.ntropy.common.client.VirtualSettlementDepositCommandClient; +import com.ntropy.common.dto.account.VirtualSettlementDepositCommand; +import com.ntropy.common.dto.account.VirtualSettlementDepositResult; + +import lombok.RequiredArgsConstructor; + +/** 플랫폼 예상 정산금을 사용자의 NTROPY 가상 수시입출금 계좌에 기록한다. */ +@Component +@RequiredArgsConstructor +public class LocalVirtualSettlementDepositCommandClient implements VirtualSettlementDepositCommandClient { + + private static final String ORDINARY_DEPOSIT_TYPE_CODE = "11"; + private static final String FINGERPRINT_TYPE = "VIRTUAL_PLATFORM_SETTLEMENT"; + + private final AccountMapper accountMapper; + private final VirtualSettlementDepositMapper depositMapper; + + @Override + @Transactional + public VirtualSettlementDepositResult createOrAdjust(VirtualSettlementDepositCommand command) { + validate(command); + Account account = findVirtualOrdinaryAccount(command.userId()); + if (account == null) { + return VirtualSettlementDepositResult.unavailable(); + } + + String settlementKey = "VS:" + TransactionFingerprint.hash( + command.userId(), + command.platformId(), + command.periodStart(), + command.periodEnd(), + command.depositDate() + ); + BigDecimal lockedBalance = depositMapper.findBalanceForUpdate(account.getId()); + BigDecimal currentBalance = lockedBalance == null ? BigDecimal.ZERO : lockedBalance; + BigDecimal generatedAmount = depositMapper.sumGeneratedAmount(account.getId(), settlementKey); + if (generatedAmount == null) { + generatedAmount = BigDecimal.ZERO; + } + BigDecimal amount = BigDecimal.valueOf(command.amount()).subtract(generatedAmount); + if (amount.signum() <= 0) { + return VirtualSettlementDepositResult.alreadyAvailable(); + } + + AccountTransaction transaction = new AccountTransaction(); + transaction.setAccountId(account.getId()); + transaction.setFingerprint(TransactionFingerprint.hash( + FINGERPRINT_TYPE, + settlementKey, + command.amount() + )); + transaction.setTransactionCategory(AccountTransactionCategory.ORDINARY); + transaction.setTranDate(command.depositDate()); + transaction.setTranTime(LocalTime.of(6, Math.floorMod(command.platformId().intValue(), 60))); + transaction.setOutAmount(BigDecimal.ZERO); + transaction.setInAmount(amount); + transaction.setAfterBalance(currentBalance.add(amount)); + // IncomingCounterpartyNameExtractor가 IBK는 desc1, 나머지 은행은 desc3을 사용한다. + transaction.setDesc1(command.depositName()); + transaction.setDesc2("정산입금"); + transaction.setDesc3(command.depositName()); + transaction.setDesc4(settlementKey); + + int inserted = depositMapper.insertIfAbsent(transaction); + if (inserted != 1) { + return VirtualSettlementDepositResult.alreadyAvailable(); + } + if (depositMapper.incrementBalanceAndAdvanceLastTranDate( + account.getId(), amount, command.depositDate()) != 1) { + throw new IllegalStateException("가상 정산 입금 계좌 잔액 갱신에 실패했습니다: accountId=" + account.getId()); + } + return VirtualSettlementDepositResult.created(); + } + + private Account findVirtualOrdinaryAccount(Long userId) { + List accounts = accountMapper.findByUserIdAndProvider(userId, ConnectionProvider.NTROPY.name()); + if (accounts == null) { + return null; + } + return accounts.stream() + .filter(account -> account.getAccountGroup() == AccountGroup.DEPOSIT_TRUST) + .filter(account -> ORDINARY_DEPOSIT_TYPE_CODE.equals(account.getDepositTypeCode())) + .findFirst() + .orElse(null); + } + + private static void validate(VirtualSettlementDepositCommand command) { + if (command == null + || command.userId() == null + || command.platformId() == null + || command.periodStart() == null + || command.periodEnd() == null + || command.depositDate() == null + || command.amount() == null + || command.amount() <= 0 + || command.depositName() == null + || command.depositName().isBlank()) { + throw new IllegalArgumentException("유효한 가상 정산 입금 명령이 필요합니다"); + } + if (command.periodStart().isAfter(command.periodEnd())) { + throw new IllegalArgumentException("정산기간 시작일은 종료일 이후일 수 없습니다"); + } + } +} diff --git a/services/account-service/src/main/java/com/ntropy/account/mapper/VirtualSettlementDepositMapper.java b/services/account-service/src/main/java/com/ntropy/account/mapper/VirtualSettlementDepositMapper.java new file mode 100644 index 00000000..9796a9e1 --- /dev/null +++ b/services/account-service/src/main/java/com/ntropy/account/mapper/VirtualSettlementDepositMapper.java @@ -0,0 +1,27 @@ +package com.ntropy.account.mapper; + +import java.math.BigDecimal; +import java.time.LocalDate; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import com.ntropy.account.domain.entity.AccountTransaction; + +/** NTROPY 가상 정산 거래의 멱등 저장과 계좌 잔액 반영 전용 Mapper. */ +@Mapper +public interface VirtualSettlementDepositMapper { + + BigDecimal findBalanceForUpdate(@Param("accountId") Long accountId); + + BigDecimal sumGeneratedAmount( + @Param("accountId") Long accountId, + @Param("settlementKey") String settlementKey); + + int insertIfAbsent(AccountTransaction transaction); + + int incrementBalanceAndAdvanceLastTranDate( + @Param("accountId") Long accountId, + @Param("amount") BigDecimal amount, + @Param("tranDate") LocalDate tranDate); +} diff --git a/services/account-service/src/main/resources/mapper/account/VirtualSettlementDepositMapper.xml b/services/account-service/src/main/resources/mapper/account/VirtualSettlementDepositMapper.xml new file mode 100644 index 00000000..3359c786 --- /dev/null +++ b/services/account-service/src/main/resources/mapper/account/VirtualSettlementDepositMapper.xml @@ -0,0 +1,42 @@ + + + + + + + + + + INSERT IGNORE INTO ACCOUNT_TRANSACTION ( + account_id, fingerprint, transaction_category, + tran_date, tran_time, out_amount, in_amount, after_balance, + desc1, desc2, desc3, desc4 + ) VALUES ( + #{accountId}, #{fingerprint}, #{transactionCategory}, + #{tranDate}, #{tranTime}, #{outAmount}, #{inAmount}, #{afterBalance}, + #{desc1}, #{desc2}, #{desc3}, #{desc4} + ) + + + + UPDATE ACCOUNT + SET balance = COALESCE(balance, 0) + #{amount}, + last_tran_date = CASE + WHEN last_tran_date IS NULL OR last_tran_date < #{tranDate} THEN #{tranDate} + ELSE last_tran_date + END + WHERE account_id = #{accountId} + + + diff --git a/services/account-service/src/test/java/com/ntropy/account/client/LocalVirtualSettlementDepositCommandClientTest.java b/services/account-service/src/test/java/com/ntropy/account/client/LocalVirtualSettlementDepositCommandClientTest.java new file mode 100644 index 00000000..4f0a4948 --- /dev/null +++ b/services/account-service/src/test/java/com/ntropy/account/client/LocalVirtualSettlementDepositCommandClientTest.java @@ -0,0 +1,174 @@ +package com.ntropy.account.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.ntropy.account.domain.AccountGroup; +import com.ntropy.account.domain.entity.Account; +import com.ntropy.account.domain.entity.AccountTransaction; +import com.ntropy.account.mapper.AccountMapper; +import com.ntropy.account.mapper.VirtualSettlementDepositMapper; +import com.ntropy.common.dto.account.VirtualSettlementDepositCommand; +import com.ntropy.common.dto.account.VirtualSettlementDepositResult; + +class LocalVirtualSettlementDepositCommandClientTest { + + @Test + void createsIncomeOnNtropyOrdinaryAccountAndUpdatesBalance() { + StubAccountMapper accountMapper = new StubAccountMapper(); + accountMapper.accounts.add(ordinaryAccount(10L, BigDecimal.valueOf(100_000L))); + RecordingDepositMapper depositMapper = new RecordingDepositMapper(); + depositMapper.lockedBalance = BigDecimal.valueOf(100_000L); + LocalVirtualSettlementDepositCommandClient client = + new LocalVirtualSettlementDepositCommandClient(accountMapper, depositMapper); + + VirtualSettlementDepositResult result = client.createOrAdjust(command(50_000L)); + + assertTrue(result.available()); + assertTrue(result.transactionCreated()); + assertEquals(10L, depositMapper.transaction.getAccountId()); + assertEquals(BigDecimal.valueOf(50_000L), depositMapper.transaction.getInAmount()); + assertEquals(BigDecimal.valueOf(150_000L), depositMapper.transaction.getAfterBalance()); + assertEquals("쿠팡이츠정산", depositMapper.transaction.getDesc1()); + assertEquals("쿠팡이츠정산", depositMapper.transaction.getDesc3()); + assertEquals(1, depositMapper.balanceUpdates); + } + + @Test + void duplicateFingerprintDoesNotIncreaseBalanceAgain() { + StubAccountMapper accountMapper = new StubAccountMapper(); + accountMapper.accounts.add(ordinaryAccount(10L, BigDecimal.valueOf(100_000L))); + RecordingDepositMapper depositMapper = new RecordingDepositMapper(); + depositMapper.lockedBalance = BigDecimal.valueOf(100_000L); + depositMapper.insertResult = 0; + LocalVirtualSettlementDepositCommandClient client = + new LocalVirtualSettlementDepositCommandClient(accountMapper, depositMapper); + + VirtualSettlementDepositResult result = client.createOrAdjust(command(50_000L)); + + assertTrue(result.available()); + assertFalse(result.transactionCreated()); + assertEquals(0, depositMapper.balanceUpdates); + } + + @Test + void createsOnlyDifferenceWhenSamePeriodTargetAmountIncreases() { + StubAccountMapper accountMapper = new StubAccountMapper(); + accountMapper.accounts.add(ordinaryAccount(10L, BigDecimal.valueOf(100_000L))); + RecordingDepositMapper depositMapper = new RecordingDepositMapper(); + depositMapper.lockedBalance = BigDecimal.valueOf(100_000L); + depositMapper.generatedAmount = BigDecimal.valueOf(50_000L); + LocalVirtualSettlementDepositCommandClient client = + new LocalVirtualSettlementDepositCommandClient(accountMapper, depositMapper); + + VirtualSettlementDepositResult result = client.createOrAdjust(command(70_000L)); + + assertTrue(result.transactionCreated()); + assertEquals(BigDecimal.valueOf(20_000L), depositMapper.transaction.getInAmount()); + assertEquals(BigDecimal.valueOf(120_000L), depositMapper.transaction.getAfterBalance()); + assertTrue(depositMapper.transaction.getDesc4().startsWith("VS:")); + } + + @Test + void alreadyFundedPeriodRemainsAvailableWithoutAnotherTransaction() { + StubAccountMapper accountMapper = new StubAccountMapper(); + accountMapper.accounts.add(ordinaryAccount(10L, BigDecimal.valueOf(150_000L))); + RecordingDepositMapper depositMapper = new RecordingDepositMapper(); + depositMapper.lockedBalance = BigDecimal.valueOf(150_000L); + depositMapper.generatedAmount = BigDecimal.valueOf(50_000L); + LocalVirtualSettlementDepositCommandClient client = + new LocalVirtualSettlementDepositCommandClient(accountMapper, depositMapper); + + VirtualSettlementDepositResult result = client.createOrAdjust(command(50_000L)); + + assertTrue(result.available()); + assertFalse(result.transactionCreated()); + assertEquals(null, depositMapper.transaction); + assertEquals(0, depositMapper.balanceUpdates); + } + + @Test + void doesNotCreateTransactionWhenVirtualOrdinaryAccountIsMissing() { + RecordingDepositMapper depositMapper = new RecordingDepositMapper(); + LocalVirtualSettlementDepositCommandClient client = + new LocalVirtualSettlementDepositCommandClient(new StubAccountMapper(), depositMapper); + + VirtualSettlementDepositResult result = client.createOrAdjust(command(50_000L)); + + assertFalse(result.available()); + assertFalse(result.transactionCreated()); + assertEquals(null, depositMapper.transaction); + } + + private static VirtualSettlementDepositCommand command(long amount) { + return new VirtualSettlementDepositCommand( + 1L, + 2L, + LocalDate.of(2026, 7, 15), + LocalDate.of(2026, 7, 21), + LocalDate.of(2026, 7, 24), + amount, + "쿠팡이츠정산" + ); + } + + private static Account ordinaryAccount(Long id, BigDecimal balance) { + Account account = new Account(); + account.setId(id); + account.setUserId(1L); + account.setAccountGroup(AccountGroup.DEPOSIT_TRUST); + account.setDepositTypeCode("11"); + account.setBalance(balance); + return account; + } + + private static final class StubAccountMapper implements AccountMapper { + private final List accounts = new ArrayList<>(); + + @Override public void upsert(Account account) { } + @Override public void updateAccountDetails(Account account) { } + @Override public Account findByConnectionIdAndAccountNoHash(Long connectionId, String hash) { return null; } + @Override public Account findByIdAndUserIdAndProvider(Long id, Long userId, String provider) { return null; } + @Override public List findByUserIdAndProvider(Long userId, String provider) { return accounts; } + @Override public boolean existsAnyByUserIdAndProvider(Long userId, String provider) { return !accounts.isEmpty(); } + @Override public void deleteByUserIdAndProvider(Long userId, String provider) { } + } + + private static final class RecordingDepositMapper implements VirtualSettlementDepositMapper { + private AccountTransaction transaction; + private int insertResult = 1; + private int balanceUpdates; + private BigDecimal lockedBalance = BigDecimal.ZERO; + private BigDecimal generatedAmount = BigDecimal.ZERO; + + @Override + public BigDecimal findBalanceForUpdate(Long accountId) { + return lockedBalance; + } + + @Override + public BigDecimal sumGeneratedAmount(Long accountId, String settlementKey) { + return generatedAmount; + } + + @Override + public int insertIfAbsent(AccountTransaction transaction) { + this.transaction = transaction; + return insertResult; + } + + @Override + public int incrementBalanceAndAdvanceLastTranDate(Long accountId, BigDecimal amount, LocalDate tranDate) { + balanceUpdates++; + return 1; + } + } +} diff --git a/services/work-service/src/main/java/com/ntropy/work/domain/ExpectedSettlementDateCalculator.java b/services/work-service/src/main/java/com/ntropy/work/domain/ExpectedSettlementDateCalculator.java new file mode 100644 index 00000000..722ce185 --- /dev/null +++ b/services/work-service/src/main/java/com/ntropy/work/domain/ExpectedSettlementDateCalculator.java @@ -0,0 +1,119 @@ +package com.ntropy.work.domain; + +import java.time.DayOfWeek; +import java.time.LocalDate; +import java.time.YearMonth; +import java.time.temporal.TemporalAdjusters; +import java.util.Set; + +import com.ntropy.work.domain.entity.Platform; + +/** 플랫폼 정산 규칙으로 근무일에서 예상 입금일을 순방향 계산한다. */ +public final class ExpectedSettlementDateCalculator { + + private static final int DEFAULT_WEEKLY_OFFSET_DAY = 1; + private static final int WEEKLY_SEARCH_LIMIT_DAYS = 62; + private static final String BUSINESS_DAY = "BUSINESS_DAY"; + + private ExpectedSettlementDateCalculator() { + } + + public static LocalDate calculate(Platform platform, LocalDate workDate, Set holidays) { + if (platform == null || workDate == null) { + throw new IllegalArgumentException("platform과 workDate가 필요합니다"); + } + Set safeHolidays = holidays == null ? Set.of() : holidays; + return switch (platform.getSettlementCycle()) { + case "DAILY" -> dailyDate(platform, workDate, safeHolidays); + case "WEEKLY" -> weeklyDate(platform, workDate, safeHolidays); + case "MONTHLY" -> monthlyDate(platform, workDate); + default -> throw new IllegalArgumentException( + "알 수 없는 정산 주기입니다: " + platform.getSettlementCycle()); + }; + } + + private static LocalDate dailyDate(Platform platform, LocalDate workDate, Set holidays) { + int offset = platform.getSettlementOffsetDay() == null ? 0 : platform.getSettlementOffsetDay(); + return addDays(workDate, offset, platform.getSettlementOffsetUnit(), holidays); + } + + private static LocalDate weeklyDate(Platform platform, LocalDate workDate, Set holidays) { + if (platform.getSettlementDayOfWeek() == null) { + int offset = platform.getSettlementOffsetDay() == null + ? DEFAULT_WEEKLY_OFFSET_DAY + : platform.getSettlementOffsetDay(); + return addDays(workDate, offset, platform.getSettlementOffsetUnit(), holidays); + } + + DayOfWeek scheduledDay = SettlementDayOfWeekParser.parse(platform.getSettlementDayOfWeek()); + LocalDate scheduledDate = workDate.with(TemporalAdjusters.nextOrSame(scheduledDay)); + for (int elapsed = 0; elapsed <= WEEKLY_SEARCH_LIMIT_DAYS; elapsed += 7) { + LocalDate candidate = scheduledDate.plusDays(elapsed); + SettlementPeriod period = SettlementPeriodCalculator.calculate(platform, candidate, holidays); + if (!workDate.isBefore(period.start()) && !workDate.isAfter(period.end())) { + return resolveWeeklyPaymentDate(candidate, period.end(), platform.getSettlementOffsetUnit(), holidays); + } + } + throw new IllegalStateException( + "근무일에 대응하는 주간 정산일을 찾지 못했습니다: platformId=" + + platform.getPlatformId() + ", workDate=" + workDate); + } + + private static LocalDate monthlyDate(Platform platform, LocalDate workDate) { + YearMonth paymentMonth = YearMonth.from(workDate).plusMonths(1); + int configuredDay = platform.getSettlementDayOfMonth() == null ? 1 : platform.getSettlementDayOfMonth(); + int day = Math.min(Math.max(configuredDay, 1), paymentMonth.lengthOfMonth()); + return paymentMonth.atDay(day); + } + + private static LocalDate addDays(LocalDate date, int days, String unit, Set holidays) { + if (!BUSINESS_DAY.equals(unit)) { + return date.plusDays(days); + } + LocalDate result = date; + int remaining = days; + while (remaining > 0) { + result = result.plusDays(1); + if (isBusinessDay(result, holidays)) { + remaining--; + } + } + return result; + } + + private static LocalDate moveToNextBusinessDayIfNeeded( + LocalDate date, String unit, Set holidays + ) { + if (!BUSINESS_DAY.equals(unit)) { + return date; + } + LocalDate result = date; + while (!isBusinessDay(result, holidays)) { + result = result.plusDays(1); + } + return result; + } + + private static LocalDate resolveWeeklyPaymentDate( + LocalDate scheduledDate, + LocalDate periodEnd, + String unit, + Set holidays + ) { + if (!BUSINESS_DAY.equals(unit)) { + return scheduledDate; + } + boolean holidayBetweenPeriodAndPayment = holidays.stream() + .anyMatch(holiday -> holiday.isAfter(periodEnd) && !holiday.isAfter(scheduledDate)); + if (holidayBetweenPeriodAndPayment) { + return moveToNextBusinessDayIfNeeded(scheduledDate.plusDays(1), unit, holidays); + } + return moveToNextBusinessDayIfNeeded(scheduledDate, unit, holidays); + } + + private static boolean isBusinessDay(LocalDate date, Set holidays) { + DayOfWeek day = date.getDayOfWeek(); + return day != DayOfWeek.SATURDAY && day != DayOfWeek.SUNDAY && !holidays.contains(date); + } + +} diff --git a/services/work-service/src/main/java/com/ntropy/work/domain/SettlementDayOfWeekParser.java b/services/work-service/src/main/java/com/ntropy/work/domain/SettlementDayOfWeekParser.java new file mode 100644 index 00000000..3fa27600 --- /dev/null +++ b/services/work-service/src/main/java/com/ntropy/work/domain/SettlementDayOfWeekParser.java @@ -0,0 +1,28 @@ +package com.ntropy.work.domain; + +import java.time.DayOfWeek; +import java.util.Locale; + +/** PLATFORM.settlement_day_of_week의 MON~SUN 값을 Java 요일로 변환한다. */ +public final class SettlementDayOfWeekParser { + + private SettlementDayOfWeekParser() { + } + + public static DayOfWeek parse(String abbreviation) { + if (abbreviation == null) { + throw new IllegalArgumentException("settlement_day_of_week 값이 필요합니다"); + } + return switch (abbreviation.toUpperCase(Locale.ROOT)) { + case "MON" -> DayOfWeek.MONDAY; + case "TUE" -> DayOfWeek.TUESDAY; + case "WED" -> DayOfWeek.WEDNESDAY; + case "THU" -> DayOfWeek.THURSDAY; + case "FRI" -> DayOfWeek.FRIDAY; + case "SAT" -> DayOfWeek.SATURDAY; + case "SUN" -> DayOfWeek.SUNDAY; + default -> throw new IllegalArgumentException( + "알 수 없는 settlement_day_of_week 값입니다: " + abbreviation); + }; + } +} diff --git a/services/work-service/src/main/java/com/ntropy/work/domain/SettlementPeriodCalculator.java b/services/work-service/src/main/java/com/ntropy/work/domain/SettlementPeriodCalculator.java index 30238c67..910e4294 100644 --- a/services/work-service/src/main/java/com/ntropy/work/domain/SettlementPeriodCalculator.java +++ b/services/work-service/src/main/java/com/ntropy/work/domain/SettlementPeriodCalculator.java @@ -17,10 +17,11 @@ * MONTHLY 기준일의 정확한 정산 규칙도 마찬가지로 검증되지 않은 추정값이다: * - MONTHLY: 입금일이 속한 달의 직전 달 전체 * - *

settlement_offset_unit이 BUSINESS_DAY인 경우(현재 배민커넥트/쿠팡이츠 배달파트너) - * offset일만큼 역산할 때 주말·공휴일을 건너뛴다. 주말 판정은 외부 데이터 없이 계산하고, - * 공휴일은 holidays 파라미터로 주입받는다 - 이 클래스는 순수 함수로 유지하고, 실제 공휴일 - * 조회(홀리데이 API/캐시)는 호출부(SettlementService)의 책임으로 둔다.

+ *

settlement_offset_unit이 BUSINESS_DAY인 경우 offset일만큼 역산할 때 주말·공휴일을 + * 건너뛴다. 다만 정산 요일이 고정된 WEEKLY 플랫폼은 실제 입금이 밀려도 정산기간 자체는 + * 유지해야 하므로 고정 요일과 기간 종료일의 달력상 간격을 사용한다. 주말 판정은 외부 데이터 + * 없이 계산하고, 공휴일은 holidays 파라미터로 주입받는다 - 이 클래스는 순수 함수로 유지하고, + * 실제 공휴일 조회(홀리데이 API/캐시)는 호출부(SettlementService)의 책임으로 둔다.

* *

DAILY + BUSINESS_DAY 조합에서는 역산된 workDate 하루가 아니라, workDate부터 그 다음 * 영업일 직전까지를 기간으로 잡는다. 주말/공휴일은 오프셋 카운트에 안 들어가므로, 그 구간 @@ -64,12 +65,19 @@ private static SettlementPeriod weeklyPeriod(Platform platform, LocalDate paymen int offset = platform.getSettlementOffsetDay() == null ? DEFAULT_WEEKLY_OFFSET_DAY : platform.getSettlementOffsetDay(); - LocalDate periodEndAnchor = subtractDays(scheduledPaymentDate, offset, platform.getSettlementOffsetUnit(), holidays); + // 정산 요일이 고정된 플랫폼은 공휴일 때문에 실제 입금만 밀릴 뿐 정산기간 자체는 + // 흔들리지 않는다(쿠팡이츠: 항상 수~화 근무분). 따라서 고정 요일과 기간 종료일의 + // 간격은 달력일로 유지한다. 요일이 없는 추정 규칙만 기존 단위 기반 역산을 사용한다. + LocalDate periodEndAnchor = platform.getSettlementDayOfWeek() == null + ? subtractDays(scheduledPaymentDate, offset, platform.getSettlementOffsetUnit(), holidays) + : scheduledPaymentDate.minusDays(offset); // periodEnd 경계일 자체가 공휴일이면 subtractDays가 그 전 영업일로 밀어버려서, 정작 // 그 경계일(공휴일)에 일한 근무일지가 7일 범위 밖으로 빠진다 - DAILY와 같은 이유로 // 같은 헬퍼로 경계일을 다시 확장해준다. - LocalDate periodEnd = extendThroughTrailingNonBusinessDays( - periodEndAnchor, platform.getSettlementOffsetUnit(), holidays); + LocalDate periodEnd = platform.getSettlementDayOfWeek() == null + ? extendThroughTrailingNonBusinessDays( + periodEndAnchor, platform.getSettlementOffsetUnit(), holidays) + : periodEndAnchor; LocalDate periodStart = periodEnd.minusDays(6); return new SettlementPeriod(periodStart, periodEnd); } @@ -86,7 +94,7 @@ private static LocalDate resolveScheduledPaymentDate(String settlementDayOfWeek, if (settlementDayOfWeek == null) { return actualPaymentDate; } - DayOfWeek scheduled = parseDayOfWeek(settlementDayOfWeek); + DayOfWeek scheduled = SettlementDayOfWeekParser.parse(settlementDayOfWeek); LocalDate date = actualPaymentDate; while (date.getDayOfWeek() != scheduled) { date = date.minusDays(1); @@ -94,19 +102,6 @@ private static LocalDate resolveScheduledPaymentDate(String settlementDayOfWeek, return date; } - private static DayOfWeek parseDayOfWeek(String abbreviation) { - return switch (abbreviation.toUpperCase(java.util.Locale.ROOT)) { - case "MON" -> DayOfWeek.MONDAY; - case "TUE" -> DayOfWeek.TUESDAY; - case "WED" -> DayOfWeek.WEDNESDAY; - case "THU" -> DayOfWeek.THURSDAY; - case "FRI" -> DayOfWeek.FRIDAY; - case "SAT" -> DayOfWeek.SATURDAY; - case "SUN" -> DayOfWeek.SUNDAY; - default -> throw new IllegalArgumentException("알 수 없는 settlement_day_of_week 값입니다: " + abbreviation); - }; - } - private static SettlementPeriod monthlyPeriod(LocalDate paymentDate) { YearMonth previousMonth = YearMonth.from(paymentDate).minusMonths(1); return new SettlementPeriod(previousMonth.atDay(1), previousMonth.atEndOfMonth()); diff --git a/services/work-service/src/main/java/com/ntropy/work/domain/VirtualSettlementDepositBatchResult.java b/services/work-service/src/main/java/com/ntropy/work/domain/VirtualSettlementDepositBatchResult.java new file mode 100644 index 00000000..f584b2d1 --- /dev/null +++ b/services/work-service/src/main/java/com/ntropy/work/domain/VirtualSettlementDepositBatchResult.java @@ -0,0 +1,22 @@ +package com.ntropy.work.domain; + +import java.time.LocalDate; +import java.util.List; + +/** 가상 입금 생성 결과와 기존 정산 배치가 즉시 재확인할 과거 입금일 목록. */ +public record VirtualSettlementDepositBatchResult( + int createdCount, + List matchTargets +) { + + public VirtualSettlementDepositBatchResult { + matchTargets = matchTargets == null ? List.of() : List.copyOf(matchTargets); + } + + public static VirtualSettlementDepositBatchResult empty() { + return new VirtualSettlementDepositBatchResult(0, List.of()); + } + + public record MatchTarget(Long userId, LocalDate depositDate) { + } +} diff --git a/services/work-service/src/main/java/com/ntropy/work/mapper/WorkLogPlatformIncomeMapper.java b/services/work-service/src/main/java/com/ntropy/work/mapper/WorkLogPlatformIncomeMapper.java index 46e460d0..68f58ea4 100644 --- a/services/work-service/src/main/java/com/ntropy/work/mapper/WorkLogPlatformIncomeMapper.java +++ b/services/work-service/src/main/java/com/ntropy/work/mapper/WorkLogPlatformIncomeMapper.java @@ -7,6 +7,7 @@ import org.apache.ibatis.annotations.Param; import com.ntropy.work.domain.entity.WorkLogPlatformIncome; +import com.ntropy.work.mapper.projection.VirtualSettlementIncome; @Mapper public interface WorkLogPlatformIncomeMapper { @@ -48,4 +49,9 @@ List findConfirmedByUserIdInAndDateRange( @Param("userIds") List userIds, @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate); + + /** 가상 정산 입금용: 기준일까지 확정된 플랫폼 소득의 누적 목표액과 PENDING 여부를 조회한다. */ + List findConfirmedByUserIdUpToDateForVirtualSettlement( + @Param("userId") Long userId, + @Param("endDate") LocalDate endDate); } diff --git a/services/work-service/src/main/java/com/ntropy/work/mapper/projection/VirtualSettlementIncome.java b/services/work-service/src/main/java/com/ntropy/work/mapper/projection/VirtualSettlementIncome.java new file mode 100644 index 00000000..faf0b9bf --- /dev/null +++ b/services/work-service/src/main/java/com/ntropy/work/mapper/projection/VirtualSettlementIncome.java @@ -0,0 +1,23 @@ +package com.ntropy.work.mapper.projection; + +import java.time.LocalDate; + +import com.ntropy.work.domain.enums.SettlementStatus; + +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** 가상 정산기간의 누적 목표액 계산에 필요한 확정 근무일지의 플랫폼별 소득. */ +@Getter +@Setter +@NoArgsConstructor +public class VirtualSettlementIncome { + + private Long incomeId; + private Long userId; + private Long platformId; + private LocalDate workDate; + private Long expectedAmount; + private SettlementStatus settlementStatus; +} diff --git a/services/work-service/src/main/java/com/ntropy/work/scheduler/SettlementScheduler.java b/services/work-service/src/main/java/com/ntropy/work/scheduler/SettlementScheduler.java index f8a0cca0..a925f1a3 100644 --- a/services/work-service/src/main/java/com/ntropy/work/scheduler/SettlementScheduler.java +++ b/services/work-service/src/main/java/com/ntropy/work/scheduler/SettlementScheduler.java @@ -1,11 +1,16 @@ package com.ntropy.work.scheduler; +import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import com.ntropy.work.domain.VirtualSettlementDepositBatchResult; import com.ntropy.work.service.SettlementService; +import com.ntropy.work.service.VirtualSettlementDepositService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -26,6 +31,7 @@ public class SettlementScheduler { private final SettlementService settlementService; + private final VirtualSettlementDepositService virtualSettlementDepositService; /** * 매일 새벽 1시 30분, 한국 시간 기준으로 실행됩니다. @@ -41,6 +47,17 @@ public class SettlementScheduler { public void runDailySettlementBatch() { log.info("[정산 배치] 일일 정산 배치 스케줄 실행 시작 (실행시각: {})", LocalDateTime.now()); + LocalDate today = LocalDate.now(); + try { + VirtualSettlementDepositBatchResult result = virtualSettlementDepositService.runDailyBatch(today); + log.info("[정산 배치] 가상계좌 정산 입금 생성 완료. createdDeposits={}, matchTargets={}", + result.createdCount(), result.matchTargets().size()); + processVirtualSettlementTargets(result, today); + } catch (Exception exception) { + // 가상계좌 입금 생성 실패가 실제 CODEF 입금의 정산 매칭을 막아서는 안 된다. + log.error("[정산 배치] 가상계좌 정산 입금 생성 중 오류 발생", exception); + } + try { settlementService.runDailyBatch(); log.info("[정산 배치] 일일 정산 배치 스케줄 실행 완료 (실행시각: {})", LocalDateTime.now()); @@ -50,4 +67,30 @@ public void runDailySettlementBatch() { LocalDateTime.now(), exception); } } + + /** 최근 3일 백필 범위 밖으로 생성된 과거 가상 입금도 같은 실행에서 즉시 매칭한다. */ + private void processVirtualSettlementTargets(VirtualSettlementDepositBatchResult result, LocalDate today) { + Map outcomesByUser = new LinkedHashMap<>(); + for (VirtualSettlementDepositBatchResult.MatchTarget target : result.matchTargets()) { + try { + SettlementService.SettlementBatchOutcome outcome = settlementService.processSettlementDetailed( + target.userId(), target.depositDate()); + if (outcome.createdCount() > 0) { + outcomesByUser.merge(target.userId(), outcome, (left, right) -> + new SettlementService.SettlementBatchOutcome( + left.createdCount() + right.createdCount(), + Math.addExact(left.totalAmount(), right.totalAmount()) + )); + } + } catch (RuntimeException e) { + log.error("[정산 배치] 가상 입금 즉시 매칭 실패. userId={}, depositDate={}", + target.userId(), target.depositDate(), e); + } + } + for (Map.Entry entry : outcomesByUser.entrySet()) { + SettlementService.SettlementBatchOutcome outcome = entry.getValue(); + settlementService.notifySettlementCompleted( + entry.getKey(), today, outcome.createdCount(), outcome.totalAmount()); + } + } } diff --git a/services/work-service/src/main/java/com/ntropy/work/service/SettlementService.java b/services/work-service/src/main/java/com/ntropy/work/service/SettlementService.java index 4e46fa95..9cb0a8e7 100644 --- a/services/work-service/src/main/java/com/ntropy/work/service/SettlementService.java +++ b/services/work-service/src/main/java/com/ntropy/work/service/SettlementService.java @@ -42,6 +42,8 @@ * 호출하는 것을 전제로 한다. 이미 같은 accountTransactionId로 처리된 거래는 재처리하지 * 않는다 - 배치가 중복 실행돼도 안전하고, 같은 잡·같은 정산기간에 서로 다른 거래가 여러 건 * 들어와도(거래 ID가 다르므로) 각각 정상적으로 반영된다. + * 각 SETTLEMENT의 expectedAmount는 기간 전체 누계가 아니라 해당 거래가 새로 COMPLETED로 + * 전환한 PENDING 소득의 합계다. 따라서 같은 기간의 top-up 거래도 기존 완료 소득을 중복 집계하지 않는다. * *

매칭되지 않은(UNMATCHED) 거래도 버리지 않고, 같은 날짜에 들어온 것들을 합산해 * status=UNMATCHED(job_id=null) 행 하나로 저장한다. 한 플랫폼에 회원 잡이 여러 개 @@ -200,7 +202,10 @@ private TransactionResult processMatchedTransaction(Long userId, NormalizedIncom List incomesInPeriod = workLogPlatformIncomeMapper .findConfirmedByJobIdAndPlatformIdAndDateRange( jobId, platform.getPlatformId(), period.start(), period.end()); - long expectedAmount = incomesInPeriod.stream() + List newlyCoveredIncomes = incomesInPeriod.stream() + .filter(income -> income.getSettlementStatus() == SettlementStatus.PENDING) + .toList(); + long expectedAmount = newlyCoveredIncomes.stream() .mapToLong(income -> income.getExpectedAmount() == null ? 0L : income.getExpectedAmount()) .sum(); @@ -220,7 +225,7 @@ private TransactionResult processMatchedTransaction(Long userId, NormalizedIncom settlementMapper.insert(settlement); Set affectedLogIds = new LinkedHashSet<>(); - for (WorkLogPlatformIncome income : incomesInPeriod) { + for (WorkLogPlatformIncome income : newlyCoveredIncomes) { income.setSettlementStatus(SettlementStatus.COMPLETED); workLogPlatformIncomeMapper.update(income); affectedLogIds.add(income.getLogId()); diff --git a/services/work-service/src/main/java/com/ntropy/work/service/VirtualSettlementDepositService.java b/services/work-service/src/main/java/com/ntropy/work/service/VirtualSettlementDepositService.java new file mode 100644 index 00000000..f0d0b04d --- /dev/null +++ b/services/work-service/src/main/java/com/ntropy/work/service/VirtualSettlementDepositService.java @@ -0,0 +1,193 @@ +package com.ntropy.work.service; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; + +import com.ntropy.common.client.ActiveUserQueryClient; +import com.ntropy.common.client.VirtualSettlementDepositCommandClient; +import com.ntropy.common.dto.account.VirtualSettlementDepositCommand; +import com.ntropy.common.dto.account.VirtualSettlementDepositResult; +import com.ntropy.work.config.SettlementBatchUserScopeProperties; +import com.ntropy.work.domain.ExpectedSettlementDateCalculator; +import com.ntropy.work.domain.SettlementPeriod; +import com.ntropy.work.domain.SettlementPeriodCalculator; +import com.ntropy.work.domain.VirtualSettlementDepositBatchResult; +import com.ntropy.work.domain.VirtualSettlementDepositBatchResult.MatchTarget; +import com.ntropy.work.domain.entity.Platform; +import com.ntropy.work.domain.enums.SettlementStatus; +import com.ntropy.work.mapper.PlatformMapper; +import com.ntropy.work.mapper.WorkLogPlatformIncomeMapper; +import com.ntropy.work.mapper.projection.VirtualSettlementIncome; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** 확정 근무일지의 플랫폼별 예상 소득을 정산일에 NTROPY 가상계좌 입금으로 만든다. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class VirtualSettlementDepositService { + + private static final String AUTO_TRIGGER = "AUTO"; + private static final String BUSINESS_DAY = "BUSINESS_DAY"; + private static final int HOLIDAY_LOOKAHEAD_DAYS = 62; + + private final ActiveUserQueryClient activeUserQueryClient; + private final SettlementBatchUserScopeProperties userScopeProperties; + private final WorkLogPlatformIncomeMapper workLogPlatformIncomeMapper; + private final PlatformMapper platformMapper; + private final HolidayService holidayService; + private final VirtualSettlementDepositCommandClient depositCommandClient; + + /** 전체 활성 사용자의 오늘까지 도래한 가상 정산 입금을 생성한다. */ + public VirtualSettlementDepositBatchResult runDailyBatch(LocalDate processDate) { + List userIds = activeUserQueryClient.findActiveUserIds(userScopeProperties.getUserScope()); + if (userIds == null || userIds.isEmpty()) { + return VirtualSettlementDepositBatchResult.empty(); + } + + int createdCount = 0; + Set matchTargets = new LinkedHashSet<>(); + for (Long userId : userIds) { + try { + VirtualSettlementDepositBatchResult result = processUser(userId, processDate); + createdCount += result.createdCount(); + matchTargets.addAll(result.matchTargets()); + } catch (RuntimeException e) { + log.error("[가상 정산 입금] 사용자 처리 실패. userId={}, processDate={}", userId, processDate, e); + } + } + return new VirtualSettlementDepositBatchResult(createdCount, new ArrayList<>(matchTargets)); + } + + /** 한 사용자의 미정산 플랫폼 소득을 정산일·정산기간별로 합산해 입금 명령을 전송한다. */ + public VirtualSettlementDepositBatchResult processUser(Long userId, LocalDate processDate) { + List incomes = + workLogPlatformIncomeMapper.findConfirmedByUserIdUpToDateForVirtualSettlement(userId, processDate); + if (incomes == null || incomes.isEmpty()) { + return VirtualSettlementDepositBatchResult.empty(); + } + + Map platforms = platformMapper.findAll().stream() + .collect(Collectors.toMap(Platform::getPlatformId, Function.identity())); + Set holidays = loadRequiredHolidays(incomes, platforms, processDate); + Map groups = new LinkedHashMap<>(); + + for (VirtualSettlementIncome income : incomes) { + Platform platform = platforms.get(income.getPlatformId()); + if (!isEligible(income, platform)) { + continue; + } + try { + LocalDate depositDate = ExpectedSettlementDateCalculator.calculate( + platform, income.getWorkDate(), holidays); + if (depositDate.isAfter(processDate)) { + continue; + } + SettlementPeriod period = SettlementPeriodCalculator.calculate(platform, depositDate, holidays); + DepositGroupKey key = new DepositGroupKey( + platform.getPlatformId(), depositDate, period.start(), period.end()); + groups.computeIfAbsent(key, ignored -> new DepositGroup()) + .add(income.getExpectedAmount(), income.getSettlementStatus()); + } catch (RuntimeException e) { + // 한 플랫폼의 잘못된 정산 규칙이 같은 사용자의 다른 정상 플랫폼 입금을 막지 않게 격리한다. + log.error("[가상 정산 입금] 플랫폼 규칙 계산 실패. userId={}, platformId={}, incomeId={}", + userId, income.getPlatformId(), income.getIncomeId(), e); + } + } + + int createdCount = 0; + Set matchTargets = new LinkedHashSet<>(); + for (Map.Entry entry : groups.entrySet()) { + if (!entry.getValue().hasPending()) { + continue; + } + DepositGroupKey key = entry.getKey(); + Platform platform = platforms.get(key.platformId()); + try { + VirtualSettlementDepositResult result = depositCommandClient.createOrAdjust( + new VirtualSettlementDepositCommand( + userId, + key.platformId(), + key.periodStart(), + key.periodEnd(), + key.depositDate(), + entry.getValue().totalAmount(), + platform.getDepositName() + )); + if (result.available()) { + matchTargets.add(new MatchTarget(userId, key.depositDate())); + } + if (result.transactionCreated()) { + createdCount++; + } + } catch (RuntimeException e) { + log.error("[가상 정산 입금] 정산기간 입금 생성 실패. userId={}, platformId={}, depositDate={}", + userId, key.platformId(), key.depositDate(), e); + } + } + return new VirtualSettlementDepositBatchResult(createdCount, new ArrayList<>(matchTargets)); + } + + private Set loadRequiredHolidays( + List incomes, + Map platforms, + LocalDate processDate + ) { + boolean needsBusinessDays = incomes.stream() + .map(income -> platforms.get(income.getPlatformId())) + .anyMatch(platform -> platform != null && BUSINESS_DAY.equals(platform.getSettlementOffsetUnit())); + if (!needsBusinessDays) { + return Set.of(); + } + LocalDate firstWorkDate = incomes.stream() + .map(VirtualSettlementIncome::getWorkDate) + .min(LocalDate::compareTo) + .orElse(processDate); + return holidayService.getHolidays(firstWorkDate, processDate.plusDays(HOLIDAY_LOOKAHEAD_DAYS)); + } + + private static boolean isEligible(VirtualSettlementIncome income, Platform platform) { + return platform != null + && AUTO_TRIGGER.equals(platform.getSettlementTriggerType()) + && platform.getDepositName() != null + && !platform.getDepositName().isBlank() + && income.getExpectedAmount() != null + && income.getExpectedAmount() > 0; + } + + private record DepositGroupKey( + Long platformId, + LocalDate depositDate, + LocalDate periodStart, + LocalDate periodEnd + ) { + } + + private static final class DepositGroup { + private long totalAmount; + private boolean hasPending; + + private void add(Long amount, SettlementStatus status) { + totalAmount = Math.addExact(totalAmount, amount); + hasPending = hasPending || status == SettlementStatus.PENDING; + } + + private long totalAmount() { + return totalAmount; + } + + private boolean hasPending() { + return hasPending; + } + } +} diff --git a/services/work-service/src/main/resources/db/work-service-schema.sql b/services/work-service/src/main/resources/db/work-service-schema.sql index 4303daf7..031091e9 100644 --- a/services/work-service/src/main/resources/db/work-service-schema.sql +++ b/services/work-service/src/main/resources/db/work-service-schema.sql @@ -128,7 +128,7 @@ CREATE TABLE `SETTLEMENT` ( `period_start` DATE NOT NULL, `period_end` DATE NOT NULL, `deposit_date` DATE NOT NULL COMMENT '실제 입금일 (ACCOUNT_TRANSACTION.tran_date)', - `expected_amount` BIGINT NOT NULL COMMENT '해당 기간 WORK_LOG_PLATFORM_INCOME 합계 스냅샷 (UNMATCHED는 0)', + `expected_amount` BIGINT NOT NULL COMMENT '이 거래가 새로 정산 완료한 WORK_LOG_PLATFORM_INCOME 예상액 합계 스냅샷 (동일 기간 top-up은 신규 PENDING분만, UNMATCHED는 0)', `actual_amount` BIGINT NOT NULL COMMENT '매칭된 실제 입금액 합계', `transaction_count` INT NOT NULL COMMENT '이 SETTLEMENT 행에 합산된 거래 건수', `account_transaction_id` BIGINT NULL COMMENT 'account-service ACCOUNT_TRANSACTION 참조 (크로스 도메인 FK 없음). UNMATCHED는 NULL', @@ -248,4 +248,4 @@ CREATE INDEX `IDX_WORK_LOG_USER_DATE` ON `WORK_LOG` (`user_id`, `work_date`); CREATE UNIQUE INDEX `UK_ALLOCATION_GOAL_JOB_MONTH` - ON `ALLOCATION_GOAL` (`job_id`, `target_month`); \ No newline at end of file + ON `ALLOCATION_GOAL` (`job_id`, `target_month`); diff --git a/services/work-service/src/main/resources/mapper/work/WorkLogPlatformIncomeMapper.xml b/services/work-service/src/main/resources/mapper/work/WorkLogPlatformIncomeMapper.xml index 614fe89a..8a8e8c7e 100644 --- a/services/work-service/src/main/resources/mapper/work/WorkLogPlatformIncomeMapper.xml +++ b/services/work-service/src/main/resources/mapper/work/WorkLogPlatformIncomeMapper.xml @@ -68,4 +68,21 @@ AND wl.work_date BETWEEN #{startDate} AND #{endDate} + + diff --git a/services/work-service/src/test/java/com/ntropy/work/domain/ExpectedSettlementDateCalculatorTest.java b/services/work-service/src/test/java/com/ntropy/work/domain/ExpectedSettlementDateCalculatorTest.java new file mode 100644 index 00000000..98c56986 --- /dev/null +++ b/services/work-service/src/test/java/com/ntropy/work/domain/ExpectedSettlementDateCalculatorTest.java @@ -0,0 +1,100 @@ +package com.ntropy.work.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.LocalDate; +import java.util.Set; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.ntropy.work.domain.entity.Platform; + +class ExpectedSettlementDateCalculatorTest { + + @Test + @DisplayName("배민커넥트는 근무일에서 3영업일 뒤를 예상 정산일로 계산한다") + void dailyBusinessDay_addsOffsetForward() { + Platform platform = Platform.builder() + .settlementCycle("DAILY") + .settlementOffsetDay(3) + .settlementOffsetUnit("BUSINESS_DAY") + .build(); + + LocalDate result = ExpectedSettlementDateCalculator.calculate( + platform, LocalDate.of(2026, 7, 16), Set.of()); + + assertEquals(LocalDate.of(2026, 7, 21), result); + } + + @Test + @DisplayName("영업일 계산은 주말과 주입된 공휴일을 건너뛴다") + void dailyBusinessDay_skipsWeekendAndHoliday() { + Platform platform = Platform.builder() + .settlementCycle("DAILY") + .settlementOffsetDay(3) + .settlementOffsetUnit("BUSINESS_DAY") + .build(); + + LocalDate result = ExpectedSettlementDateCalculator.calculate( + platform, + LocalDate.of(2026, 7, 16), + Set.of(LocalDate.of(2026, 7, 20))); + + assertEquals(LocalDate.of(2026, 7, 22), result); + } + + @Test + @DisplayName("쿠팡이츠는 전주 수요일부터 이번주 화요일 근무분을 이번주 금요일로 계산한다") + void weekly_resolvesContainingSettlementPeriod() { + Platform platform = Platform.builder() + .platformId(2L) + .settlementCycle("WEEKLY") + .settlementOffsetDay(3) + .settlementOffsetUnit("BUSINESS_DAY") + .settlementDayOfWeek("FRI") + .build(); + + LocalDate result = ExpectedSettlementDateCalculator.calculate( + platform, LocalDate.of(2026, 7, 16), Set.of()); + + assertEquals(LocalDate.of(2026, 7, 24), result); + } + + @Test + @DisplayName("주간 정산기간과 예정일 사이에 공휴일이 있으면 입금일만 다음 영업일로 미룬다") + void weeklyHoliday_delaysPaymentWithoutChangingPeriod() { + Platform platform = Platform.builder() + .platformId(2L) + .settlementCycle("WEEKLY") + .settlementOffsetDay(3) + .settlementOffsetUnit("BUSINESS_DAY") + .settlementDayOfWeek("FRI") + .build(); + + LocalDate result = ExpectedSettlementDateCalculator.calculate( + platform, + LocalDate.of(2026, 7, 16), + Set.of(LocalDate.of(2026, 7, 22))); + + assertEquals(LocalDate.of(2026, 7, 27), result); + SettlementPeriod period = SettlementPeriodCalculator.calculate(platform, result, + Set.of(LocalDate.of(2026, 7, 22))); + assertEquals(LocalDate.of(2026, 7, 15), period.start()); + assertEquals(LocalDate.of(2026, 7, 21), period.end()); + } + + @Test + @DisplayName("월 정산은 근무월의 다음 달 지정 일자로 계산한다") + void monthly_usesConfiguredDayInNextMonth() { + Platform platform = Platform.builder() + .settlementCycle("MONTHLY") + .settlementDayOfMonth(21) + .build(); + + LocalDate result = ExpectedSettlementDateCalculator.calculate( + platform, LocalDate.of(2026, 7, 16), Set.of()); + + assertEquals(LocalDate.of(2026, 8, 21), result); + } +} diff --git a/services/work-service/src/test/java/com/ntropy/work/mapper/InMemoryWorkLogPlatformIncomeMapper.java b/services/work-service/src/test/java/com/ntropy/work/mapper/InMemoryWorkLogPlatformIncomeMapper.java index 6194e393..d89a822e 100644 --- a/services/work-service/src/test/java/com/ntropy/work/mapper/InMemoryWorkLogPlatformIncomeMapper.java +++ b/services/work-service/src/test/java/com/ntropy/work/mapper/InMemoryWorkLogPlatformIncomeMapper.java @@ -8,6 +8,7 @@ import com.ntropy.work.domain.entity.WorkLog; import com.ntropy.work.domain.entity.WorkLogPlatformIncome; +import com.ntropy.work.mapper.projection.VirtualSettlementIncome; /** * 테스트용 인메모리 WorkLogPlatformIncomeMapper 구현체. @@ -112,4 +113,30 @@ public List findConfirmedByUserIdInAndDateRange( } return result; } + + @Override + public List findConfirmedByUserIdUpToDateForVirtualSettlement( + Long userId, LocalDate endDate) { + List result = new ArrayList<>(); + for (WorkLogPlatformIncome income : store.values()) { + WorkLog workLog = workLogMapper.findById(income.getLogId()); + if (workLog == null || !userId.equals(workLog.getUserId())) { + continue; + } + if (!"CONFIRMED".equals(workLog.getStatus()) + || workLog.getWorkDate() == null + || workLog.getWorkDate().isAfter(endDate)) { + continue; + } + VirtualSettlementIncome projection = new VirtualSettlementIncome(); + projection.setIncomeId(income.getIncomeId()); + projection.setUserId(workLog.getUserId()); + projection.setPlatformId(income.getPlatformId()); + projection.setWorkDate(workLog.getWorkDate()); + projection.setExpectedAmount(income.getExpectedAmount()); + projection.setSettlementStatus(income.getSettlementStatus()); + result.add(projection); + } + return result; + } } diff --git a/services/work-service/src/test/java/com/ntropy/work/scheduler/SettlementSchedulerTest.java b/services/work-service/src/test/java/com/ntropy/work/scheduler/SettlementSchedulerTest.java new file mode 100644 index 00000000..e8c4e849 --- /dev/null +++ b/services/work-service/src/test/java/com/ntropy/work/scheduler/SettlementSchedulerTest.java @@ -0,0 +1,83 @@ +package com.ntropy.work.scheduler; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.LocalDate; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import com.ntropy.work.domain.VirtualSettlementDepositBatchResult; +import com.ntropy.work.domain.VirtualSettlementDepositBatchResult.MatchTarget; +import com.ntropy.work.service.SettlementService; +import com.ntropy.work.service.VirtualSettlementDepositService; + +class SettlementSchedulerTest { + + @Test + void immediatelyMatchesBackdatedVirtualDepositOutsideRegularBackfillWindow() { + LocalDate today = LocalDate.now(); + LocalDate oldDepositDate = today.minusDays(30); + StubVirtualDepositService virtualService = new StubVirtualDepositService( + new VirtualSettlementDepositBatchResult(1, List.of(new MatchTarget(1L, oldDepositDate)))); + RecordingSettlementService settlementService = new RecordingSettlementService(oldDepositDate); + SettlementScheduler scheduler = new SettlementScheduler(settlementService, virtualService); + + scheduler.runDailySettlementBatch(); + + assertEquals(oldDepositDate, settlementService.processedDate); + assertEquals(1L, settlementService.notifiedUserId); + assertEquals(1, settlementService.notifiedCount); + assertEquals(50_000L, settlementService.notifiedAmount); + assertTrue(settlementService.dailyBatchCalled); + } + + private static final class StubVirtualDepositService extends VirtualSettlementDepositService { + private final VirtualSettlementDepositBatchResult result; + + private StubVirtualDepositService(VirtualSettlementDepositBatchResult result) { + super(null, null, null, null, null, null); + this.result = result; + } + + @Override + public VirtualSettlementDepositBatchResult runDailyBatch(LocalDate processDate) { + return result; + } + } + + private static final class RecordingSettlementService extends SettlementService { + private final LocalDate expectedDate; + private LocalDate processedDate; + private Long notifiedUserId; + private int notifiedCount; + private long notifiedAmount; + private boolean dailyBatchCalled; + + private RecordingSettlementService(LocalDate expectedDate) { + super(null, null, null, null, null, null, null, null, null, null, null); + this.expectedDate = expectedDate; + } + + @Override + public SettlementBatchOutcome processSettlementDetailed(Long userId, LocalDate processDate) { + processedDate = processDate; + return expectedDate.equals(processDate) + ? new SettlementBatchOutcome(1, 50_000L) + : new SettlementBatchOutcome(0, 0L); + } + + @Override + public void notifySettlementCompleted(Long userId, LocalDate today, int count, long totalAmount) { + notifiedUserId = userId; + notifiedCount = count; + notifiedAmount = totalAmount; + } + + @Override + public void runDailyBatch() { + dailyBatchCalled = true; + } + } +} diff --git a/services/work-service/src/test/java/com/ntropy/work/service/SettlementServiceTest.java b/services/work-service/src/test/java/com/ntropy/work/service/SettlementServiceTest.java index 1dea8cb9..47481f59 100644 --- a/services/work-service/src/test/java/com/ntropy/work/service/SettlementServiceTest.java +++ b/services/work-service/src/test/java/com/ntropy/work/service/SettlementServiceTest.java @@ -195,6 +195,44 @@ void processSettlement_twoDistinctTransactionsSamePeriod_bothCreateSettlements() assertEquals(50_000L, totalActualAmount); } + @Test + @DisplayName("같은 정산기간의 늦은 소득을 top-up하면 신규 소득만 expectedAmount에 반영된다") + void processSettlement_topUpSamePeriod_doesNotDuplicateCompletedExpectedAmount() { + jobPlatformMappingMapper.insert(JobPlatformMapping.builder().jobId(JOB_ID).platformId(PLATFORM_ID).build()); + WorkLog first = workLog(PERIOD_DATE, "CONFIRMED", 40_000L); + WorkLog second = workLog(PERIOD_DATE, "CONFIRMED", 10_000L); + workLogMapper.insert(first); + workLogMapper.insert(second); + insertIncome(first, PLATFORM_ID, 40_000L, SettlementStatus.PENDING); + insertIncome(second, PLATFORM_ID, 10_000L, SettlementStatus.PENDING); + + incomingTransactionQueryClient.transactions = List.of(transaction(111L, 50_000L)); + service.processSettlement(USER_ID, PROCESS_DATE); + + WorkLog late = workLog(PERIOD_DATE, "CONFIRMED", 20_000L); + workLogMapper.insert(late); + insertIncome(late, PLATFORM_ID, 20_000L, SettlementStatus.PENDING); + incomingTransactionQueryClient.transactions = List.of(transaction(222L, 20_000L)); + service.processSettlement(USER_ID, PROCESS_DATE); + + List settlements = settlementMapper.findAll(); + assertEquals(2, settlements.size()); + Settlement initial = settlements.stream() + .filter(settlement -> settlement.getAccountTransactionId().equals(111L)) + .findFirst() + .orElseThrow(); + Settlement topUp = settlements.stream() + .filter(settlement -> settlement.getAccountTransactionId().equals(222L)) + .findFirst() + .orElseThrow(); + assertEquals(50_000L, initial.getExpectedAmount()); + assertEquals(20_000L, topUp.getExpectedAmount()); + assertEquals(70_000L, settlements.stream().mapToLong(Settlement::getExpectedAmount).sum()); + assertEquals(70_000L, settlements.stream().mapToLong(Settlement::getActualAmount).sum()); + assertEquals(SettlementStatus.COMPLETED, + workLogPlatformIncomeMapper.findByLogId(late.getLogId()).get(0).getSettlementStatus()); + } + @Test @DisplayName("이미 같은 날짜의 UNMATCHED SETTLEMENT가 있으면 중복 생성하지 않는다") void processSettlement_alreadyUnmatchedSettled_skipsDuplicate() { diff --git a/services/work-service/src/test/java/com/ntropy/work/service/VirtualSettlementDepositServiceTest.java b/services/work-service/src/test/java/com/ntropy/work/service/VirtualSettlementDepositServiceTest.java new file mode 100644 index 00000000..e7e07cc0 --- /dev/null +++ b/services/work-service/src/test/java/com/ntropy/work/service/VirtualSettlementDepositServiceTest.java @@ -0,0 +1,214 @@ +package com.ntropy.work.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +import com.ntropy.common.client.VirtualSettlementDepositCommandClient; +import com.ntropy.common.dto.account.VirtualSettlementDepositCommand; +import com.ntropy.common.dto.account.VirtualSettlementDepositResult; +import com.ntropy.work.config.SettlementBatchUserScopeProperties; +import com.ntropy.work.domain.VirtualSettlementDepositBatchResult; +import com.ntropy.work.domain.entity.Platform; +import com.ntropy.work.domain.entity.WorkLogPlatformIncome; +import com.ntropy.work.domain.enums.SettlementStatus; +import com.ntropy.work.mapper.PlatformMapper; +import com.ntropy.work.mapper.WorkLogPlatformIncomeMapper; +import com.ntropy.work.mapper.projection.VirtualSettlementIncome; + +class VirtualSettlementDepositServiceTest { + + private final StubIncomeMapper incomeMapper = new StubIncomeMapper(); + private final StubPlatformMapper platformMapper = new StubPlatformMapper(); + private final RecordingDepositClient depositClient = new RecordingDepositClient(); + private final VirtualSettlementDepositService service = new VirtualSettlementDepositService( + scope -> List.of(1L), + new SettlementBatchUserScopeProperties("ALL"), + incomeMapper, + platformMapper, + new StubHolidayService(Set.of()), + depositClient + ); + + @Test + void groupsExpectedIncomeByPlatformSettlementPeriod() { + platformMapper.platforms = List.of( + platform(1L, "배민", "DAILY", "AUTO", 3, "BUSINESS_DAY", null, null), + platform(2L, "쿠팡이츠정산", "WEEKLY", "AUTO", 3, "BUSINESS_DAY", "FRI", null) + ); + incomeMapper.pending = List.of( + income(1L, LocalDate.of(2026, 7, 16), 40_000L, SettlementStatus.PENDING), + income(2L, LocalDate.of(2026, 7, 16), 30_000L, SettlementStatus.PENDING), + income(2L, LocalDate.of(2026, 7, 20), 20_000L, SettlementStatus.PENDING) + ); + + VirtualSettlementDepositBatchResult result = service.processUser(1L, LocalDate.of(2026, 7, 24)); + + assertEquals(2, result.createdCount()); + assertEquals(2, result.matchTargets().size()); + assertEquals(2, depositClient.commands.size()); + VirtualSettlementDepositCommand baemin = depositClient.commands.get(0); + assertEquals(LocalDate.of(2026, 7, 21), baemin.depositDate()); + assertEquals(40_000L, baemin.amount()); + VirtualSettlementDepositCommand coupang = depositClient.commands.get(1); + assertEquals(LocalDate.of(2026, 7, 24), coupang.depositDate()); + assertEquals(LocalDate.of(2026, 7, 15), coupang.periodStart()); + assertEquals(LocalDate.of(2026, 7, 21), coupang.periodEnd()); + assertEquals(50_000L, coupang.amount()); + } + + @Test + void skipsFutureAndOnDemandSettlements() { + platformMapper.platforms = List.of( + platform(1L, "배민", "DAILY", "AUTO", 3, "BUSINESS_DAY", null, null), + platform(4L, "카카오모빌리티", "DAILY", "ON_DEMAND", 1, "CALENDAR_DAY", null, null) + ); + incomeMapper.pending = List.of( + income(1L, LocalDate.of(2026, 7, 16), 40_000L, SettlementStatus.PENDING), + income(4L, LocalDate.of(2026, 7, 16), 30_000L, SettlementStatus.COMPLETED) + ); + + VirtualSettlementDepositBatchResult result = service.processUser(1L, LocalDate.of(2026, 7, 20)); + + assertEquals(0, result.createdCount()); + assertEquals(0, depositClient.commands.size()); + } + + @Test + void includesCompletedIncomeInCumulativeTargetWhenLateIncomeIsPending() { + platformMapper.platforms = List.of( + platform(2L, "쿠팡이츠정산", "WEEKLY", "AUTO", 3, "BUSINESS_DAY", "FRI", null) + ); + incomeMapper.pending = List.of( + income(2L, LocalDate.of(2026, 7, 16), 50_000L, SettlementStatus.COMPLETED), + income(2L, LocalDate.of(2026, 7, 20), 20_000L, SettlementStatus.PENDING) + ); + + VirtualSettlementDepositBatchResult result = service.processUser(1L, LocalDate.of(2026, 7, 30)); + + assertEquals(1, result.createdCount()); + assertEquals(70_000L, depositClient.commands.get(0).amount()); + assertEquals(LocalDate.of(2026, 7, 24), result.matchTargets().get(0).depositDate()); + } + + @Test + void invalidPlatformRuleDoesNotBlockOtherPlatform() { + platformMapper.platforms = List.of( + platform(1L, "배민", "DAILY", "AUTO", 3, "BUSINESS_DAY", null, null), + platform(99L, "오류플랫폼", null, "AUTO", 1, "CALENDAR_DAY", null, null) + ); + incomeMapper.pending = List.of( + income(99L, LocalDate.of(2026, 7, 16), 10_000L, SettlementStatus.PENDING), + income(1L, LocalDate.of(2026, 7, 16), 40_000L, SettlementStatus.PENDING) + ); + + VirtualSettlementDepositBatchResult result = service.processUser(1L, LocalDate.of(2026, 7, 24)); + + assertEquals(1, result.createdCount()); + assertEquals(1, depositClient.commands.size()); + assertEquals(1L, depositClient.commands.get(0).platformId()); + } + + @Test + void returnsPastMatchTargetEvenWhenDepositWasAlreadyGenerated() { + platformMapper.platforms = List.of( + platform(1L, "배민", "DAILY", "AUTO", 3, "BUSINESS_DAY", null, null) + ); + incomeMapper.pending = List.of( + income(1L, LocalDate.of(2026, 6, 1), 40_000L, SettlementStatus.PENDING) + ); + depositClient.nextResult = VirtualSettlementDepositResult.alreadyAvailable(); + + VirtualSettlementDepositBatchResult result = service.processUser(1L, LocalDate.of(2026, 7, 24)); + + assertEquals(0, result.createdCount()); + assertEquals(1, result.matchTargets().size()); + assertEquals(LocalDate.of(2026, 6, 4), result.matchTargets().get(0).depositDate()); + } + + private static VirtualSettlementIncome income( + Long platformId, LocalDate workDate, Long amount, SettlementStatus status + ) { + VirtualSettlementIncome income = new VirtualSettlementIncome(); + income.setIncomeId(platformId); + income.setUserId(1L); + income.setPlatformId(platformId); + income.setWorkDate(workDate); + income.setExpectedAmount(amount); + income.setSettlementStatus(status); + return income; + } + + private static Platform platform(Long id, String depositName, String cycle, String trigger, + Integer offset, String unit, String dayOfWeek, Integer dayOfMonth) { + return Platform.builder() + .platformId(id) + .depositName(depositName) + .settlementCycle(cycle) + .settlementTriggerType(trigger) + .settlementOffsetDay(offset) + .settlementOffsetUnit(unit) + .settlementDayOfWeek(dayOfWeek) + .settlementDayOfMonth(dayOfMonth) + .build(); + } + + private static final class StubIncomeMapper implements WorkLogPlatformIncomeMapper { + private List pending = List.of(); + + @Override public void insert(WorkLogPlatformIncome income) { } + @Override public void update(WorkLogPlatformIncome income) { } + @Override public List findByLogId(Long logId) { return List.of(); } + @Override public void deleteByLogId(Long logId) { } + @Override public List findConfirmedByJobIdAndPlatformIdAndDateRange( + Long jobId, Long platformId, LocalDate startDate, LocalDate endDate) { return List.of(); } + @Override public List findConfirmedByUserIdAndDateRange( + Long userId, LocalDate startDate, LocalDate endDate) { return List.of(); } + @Override public List findConfirmedByUserIdInAndDateRange( + List userIds, LocalDate startDate, LocalDate endDate) { return List.of(); } + @Override public List findConfirmedByUserIdUpToDateForVirtualSettlement( + Long userId, LocalDate endDate) { return pending; } + } + + private static final class StubPlatformMapper implements PlatformMapper { + private List platforms = List.of(); + + @Override public void insert(Platform platform) { } + @Override public Platform findById(Long platformId) { + return platforms.stream().filter(platform -> platformId.equals(platform.getPlatformId())).findFirst().orElse(null); + } + @Override public List findAll() { return platforms; } + @Override public void update(Platform platform) { } + @Override public void deleteById(Long platformId) { } + } + + private static final class StubHolidayService extends HolidayService { + private final Set holidays; + + private StubHolidayService(Set holidays) { + super(null, null); + this.holidays = holidays; + } + + @Override + public Set getHolidays(LocalDate startDate, LocalDate endDate) { + return holidays; + } + } + + private static final class RecordingDepositClient implements VirtualSettlementDepositCommandClient { + private final List commands = new ArrayList<>(); + private VirtualSettlementDepositResult nextResult = VirtualSettlementDepositResult.created(); + + @Override + public VirtualSettlementDepositResult createOrAdjust(VirtualSettlementDepositCommand command) { + commands.add(command); + return nextResult; + } + } +}