feat: complete Spring Boot 카드 매입·정산 system (acquire-core migration target, mvn verify green, 24 tests)

This commit is contained in:
forge-bot 2026-07-18 15:27:41 +00:00
commit 0f6e3acfe6
92 changed files with 4320 additions and 0 deletions

91
app/pom.xml Normal file
View file

@ -0,0 +1,91 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.klaro.acquiring</groupId>
<artifactId>card-acquiring-boot</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>app</artifactId>
<name>app</name>
<description>Spring Boot 부트 모듈: 전 모듈 조립 + REST + 배치 런처</description>
<dependencies>
<dependency>
<groupId>com.klaro.acquiring</groupId>
<artifactId>gateway</artifactId>
</dependency>
<dependency>
<groupId>com.klaro.acquiring</groupId>
<artifactId>acquiring</artifactId>
</dependency>
<dependency>
<groupId>com.klaro.acquiring</groupId>
<artifactId>reconcile</artifactId>
</dependency>
<dependency>
<groupId>com.klaro.acquiring</groupId>
<artifactId>settlement</artifactId>
</dependency>
<dependency>
<groupId>com.klaro.acquiring</groupId>
<artifactId>payment</artifactId>
</dependency>
<dependency>
<groupId>com.klaro.acquiring</groupId>
<artifactId>ledger</artifactId>
</dependency>
<dependency>
<groupId>com.klaro.acquiring</groupId>
<artifactId>master</artifactId>
</dependency>
<dependency>
<groupId>com.klaro.acquiring</groupId>
<artifactId>closing</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-batch</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.batch</groupId>
<artifactId>spring-batch-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<skip>false</skip>
<mainClass>com.klaro.acquiring.app.CardAcquiringApplication</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>

View file

@ -0,0 +1,19 @@
package com.klaro.acquiring.app;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.domain.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
/**
* 카드 매입·정산 시스템 부트 애플리케이션. 모듈(mg/ac/rc/vl/st/py/lg/mm/cm/cl) 조립한다.
*/
@SpringBootApplication(scanBasePackages = "com.klaro.acquiring")
@EntityScan(basePackages = "com.klaro.acquiring.domain.entity")
@EnableJpaRepositories(basePackages = "com.klaro.acquiring.persistence.repository")
public class CardAcquiringApplication {
public static void main(String[] args) {
SpringApplication.run(CardAcquiringApplication.class, args);
}
}

View file

@ -0,0 +1,47 @@
package com.klaro.acquiring.app.web;
import com.klaro.acquiring.acquiring.AcquiringService;
import com.klaro.acquiring.acquiring.dto.PurchaseCommand;
import com.klaro.acquiring.common.dto.ApiResponse;
import com.klaro.acquiring.domain.entity.Purchase;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
/** 매입(ac) 온라인 REST. */
@RestController
@RequestMapping("/api/acquiring/purchases")
public class AcquiringController {
private final AcquiringService acquiringService;
public AcquiringController(AcquiringService acquiringService) {
this.acquiringService = acquiringService;
}
@PostMapping
public ApiResponse<PurchaseView> accept(@Valid @RequestBody PurchaseAcceptRequest req) {
Purchase p = acquiringService.acceptPurchase(new PurchaseCommand(req.apprNo(), req.amount()));
return ApiResponse.ok(PurchaseView.from(p));
}
@GetMapping("/{apprNo}")
public ApiResponse<PurchaseView> get(@PathVariable String apprNo) {
return ApiResponse.ok(PurchaseView.from(acquiringService.getByApprNo(apprNo)));
}
public record PurchaseView(Long purchaseId, String apprNo, String merchId, BigDecimal amount,
BigDecimal mdrFee, BigDecimal vanFee, BigDecimal netAmount,
String status) {
static PurchaseView from(Purchase p) {
return new PurchaseView(p.getPurchaseId(), p.getApprNo(), p.getMerchId(), p.getAmount(),
p.getMdrFee(), p.getVanFee(), p.getNetAmount(), p.getStatus().name());
}
}
}

View file

@ -0,0 +1,58 @@
package com.klaro.acquiring.app.web;
import com.klaro.acquiring.common.dto.ApiResponse;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobParameters;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 배치 잡 런처 REST (대사/정산/마감). */
@RestController
@RequestMapping("/api/batch")
public class BatchController {
private final JobLauncher jobLauncher;
private final Job reconcileJob;
private final Job settlementJob;
private final Job closingJob;
public BatchController(JobLauncher jobLauncher,
@Qualifier("reconcileJob") Job reconcileJob,
@Qualifier("settlementJob") Job settlementJob,
@Qualifier("closingJob") Job closingJob) {
this.jobLauncher = jobLauncher;
this.reconcileJob = reconcileJob;
this.settlementJob = settlementJob;
this.closingJob = closingJob;
}
@PostMapping("/reconcile")
public ApiResponse<String> reconcile(@RequestParam String bizDate) throws Exception {
return run(reconcileJob, bizDate);
}
@PostMapping("/settlement")
public ApiResponse<String> settlement(@RequestParam String bizDate) throws Exception {
return run(settlementJob, bizDate);
}
@PostMapping("/closing")
public ApiResponse<String> closing(@RequestParam String bizDate) throws Exception {
return run(closingJob, bizDate);
}
private ApiResponse<String> run(Job job, String bizDate) throws Exception {
JobParameters params = new JobParametersBuilder()
.addString("bizDate", bizDate)
.addLong("ts", System.currentTimeMillis())
.toJobParameters();
JobExecution exec = jobLauncher.run(job, params);
return ApiResponse.ok(exec.getStatus().name());
}
}

View file

@ -0,0 +1,26 @@
package com.klaro.acquiring.app.web;
import com.klaro.acquiring.gateway.GatewayService;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/** 전문 게이트웨이(mg) REST. 고정길이 전문 in/out(text/plain). */
@RestController
@RequestMapping("/api/gateway")
public class GatewayController {
private final GatewayService gatewayService;
public GatewayController(GatewayService gatewayService) {
this.gatewayService = gatewayService;
}
@PostMapping(value = "/message", consumes = MediaType.TEXT_PLAIN_VALUE,
produces = MediaType.TEXT_PLAIN_VALUE)
public String message(@RequestBody String message) {
return gatewayService.handle(message);
}
}

View file

@ -0,0 +1,45 @@
package com.klaro.acquiring.app.web;
import com.klaro.acquiring.common.dto.ApiResponse;
import com.klaro.acquiring.common.error.ErrorCode;
import com.klaro.acquiring.common.error.AcquiringException;
import com.klaro.acquiring.common.error.NotFoundException;
import com.klaro.acquiring.common.error.ValidationException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/** 도메인 예외 → 표준 응답 매핑. */
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(NotFoundException.class)
public ResponseEntity<ApiResponse<Void>> handleNotFound(NotFoundException e) {
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(ApiResponse.error(e.getErrorCode(), e.getMessage()));
}
@ExceptionHandler(ValidationException.class)
public ResponseEntity<ApiResponse<Void>> handleValidation(ValidationException e) {
return ResponseEntity.badRequest()
.body(ApiResponse.error(e.getErrorCode(), e.getMessage()));
}
@ExceptionHandler(AcquiringException.class)
public ResponseEntity<ApiResponse<Void>> handleDomain(AcquiringException e) {
return ResponseEntity.unprocessableEntity()
.body(ApiResponse.error(e.getErrorCode(), e.getMessage()));
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleBeanValidation(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(f -> f.getField() + ": " + f.getDefaultMessage())
.orElse("요청값 오류");
return ResponseEntity.badRequest()
.body(ApiResponse.error(ErrorCode.INVALID_REQUEST, msg));
}
}

View file

@ -0,0 +1,13 @@
package com.klaro.acquiring.app.web;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.math.BigDecimal;
/** 매입 접수 REST 요청. */
public record PurchaseAcceptRequest(
@NotBlank String apprNo,
@NotNull @DecimalMin(value = "0.01") BigDecimal amount) {
}

View file

@ -0,0 +1,51 @@
package com.klaro.acquiring.app.web;
import com.klaro.acquiring.common.dto.ApiResponse;
import com.klaro.acquiring.common.util.DateUtil;
import com.klaro.acquiring.domain.entity.Settlement;
import com.klaro.acquiring.settlement.SettlementService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
/** 정산(st) 조회 REST. */
@RestController
@RequestMapping("/api/settlement")
public class SettlementController {
private final SettlementService settlementService;
public SettlementController(SettlementService settlementService) {
this.settlementService = settlementService;
}
@GetMapping
public ApiResponse<List<SettlementView>> byBizDate(@RequestParam String bizDate) {
LocalDate date = DateUtil.parse(bizDate);
return ApiResponse.ok(settlementService.listByBizDate(date).stream()
.map(SettlementView::from).toList());
}
@GetMapping("/merchant")
public ApiResponse<SettlementView> byMerchant(@RequestParam String merchId,
@RequestParam String bizDate) {
LocalDate date = DateUtil.parse(bizDate);
return ApiResponse.ok(SettlementView.from(settlementService.get(merchId, date)));
}
public record SettlementView(Long settlementId, String merchId, String bizDate,
BigDecimal grossAmount, BigDecimal totalFee, BigDecimal netAmount,
int txnCount, String payDate, String status) {
static SettlementView from(Settlement s) {
return new SettlementView(s.getSettlementId(), s.getMerchId(),
DateUtil.format(s.getBizDate()), s.getGrossAmount(), s.getTotalFee(),
s.getNetAmount(), s.getTxnCount(), DateUtil.format(s.getPayDate()),
s.getStatus().name());
}
}
}

View file

@ -0,0 +1,32 @@
spring:
application:
name: card-acquiring-boot
datasource:
url: jdbc:h2:mem:acquiring;DB_CLOSE_DELAY=-1;MODE=PostgreSQL
driver-class-name: org.h2.Driver
username: sa
password: ""
jpa:
hibernate:
ddl-auto: none
open-in-view: false
properties:
hibernate.jdbc.time_zone: Asia/Seoul
flyway:
enabled: true
locations: classpath:db/migration
batch:
job:
enabled: false
jdbc:
initialize-schema: always
h2:
console:
enabled: true
server:
port: 8080
logging:
level:
com.klaro.acquiring: INFO

View file

@ -0,0 +1,77 @@
package com.klaro.acquiring.app;
import com.klaro.acquiring.acquiring.AcquiringService;
import com.klaro.acquiring.acquiring.dto.PurchaseCommand;
import com.klaro.acquiring.common.error.AcquiringException;
import com.klaro.acquiring.domain.entity.Approval;
import com.klaro.acquiring.domain.entity.Purchase;
import com.klaro.acquiring.domain.enums.TxStatus;
import com.klaro.acquiring.persistence.repository.ApprovalRepository;
import com.klaro.acquiring.persistence.repository.FeeRepository;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/** 매입 서비스 + 리포지토리 슬라이스(@SpringBootTest, H2). */
@SpringBootTest
@Transactional
class AcquiringServiceTest {
@Autowired
private AcquiringService acquiringService;
@Autowired
private ApprovalRepository approvalRepository;
@Autowired
private FeeRepository feeRepository;
@Test
void acceptsPurchaseAndComputesFee() {
LocalDate bizDate = LocalDate.of(2026, 7, 17);
approvalRepository.save(new Approval("IT-A-0001", "M0000000000001", "123456******7890",
new BigDecimal("10000.00"), "20260717", bizDate));
Purchase p = acquiringService.acceptPurchase(new PurchaseCommand("IT-A-0001",
new BigDecimal("10000.00")));
// MDR 2.3% = 230, VAN 30 => fee 260, net 9740
assertThat(p.getMdrFee()).isEqualByComparingTo("230");
assertThat(p.getVanFee()).isEqualByComparingTo("30");
assertThat(p.getNetAmount()).isEqualByComparingTo("9740");
assertThat(p.getStatus()).isEqualTo(TxStatus.DONE);
assertThat(feeRepository.findByPurchaseId(p.getPurchaseId())).hasSize(2);
assertThat(approvalRepository.findByApprNo("IT-A-0001").orElseThrow().getStatus())
.isEqualTo(TxStatus.DONE);
}
@Test
void rejectsDuplicatePurchase() {
LocalDate bizDate = LocalDate.of(2026, 7, 17);
approvalRepository.save(new Approval("IT-A-0002", "M0000000000001", "123456******7890",
new BigDecimal("5000.00"), "20260717", bizDate));
acquiringService.acceptPurchase(new PurchaseCommand("IT-A-0002", new BigDecimal("5000.00")));
assertThatThrownBy(() -> acquiringService.acceptPurchase(
new PurchaseCommand("IT-A-0002", new BigDecimal("5000.00"))))
.isInstanceOf(AcquiringException.class)
.hasMessageContaining("이미 매입");
}
@Test
void rejectsCaptureOverApproval() {
LocalDate bizDate = LocalDate.of(2026, 7, 17);
approvalRepository.save(new Approval("IT-A-0003", "M0000000000001", "123456******7890",
new BigDecimal("5000.00"), "20260717", bizDate));
assertThatThrownBy(() -> acquiringService.acceptPurchase(
new PurchaseCommand("IT-A-0003", new BigDecimal("9000.00"))))
.isInstanceOf(AcquiringException.class)
.hasMessageContaining("초과");
}
}

View file

@ -0,0 +1,13 @@
package com.klaro.acquiring.app;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
/** 전 모듈 조립(ApplicationContext) 로드 검증. */
@SpringBootTest
class CardAcquiringApplicationTests {
@Test
void contextLoads() {
}
}

View file

@ -0,0 +1,80 @@
package com.klaro.acquiring.app;
import com.klaro.acquiring.acquiring.AcquiringService;
import com.klaro.acquiring.acquiring.dto.PurchaseCommand;
import com.klaro.acquiring.domain.entity.Approval;
import com.klaro.acquiring.domain.entity.Deposit;
import com.klaro.acquiring.domain.entity.ReconcileResult;
import com.klaro.acquiring.persistence.repository.ApprovalRepository;
import com.klaro.acquiring.persistence.repository.DepositRepository;
import com.klaro.acquiring.persistence.repository.ReconcileResultRepository;
import org.junit.jupiter.api.Test;
import org.springframework.batch.core.BatchStatus;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobParameters;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.test.JobLauncherTestUtils;
import org.springframework.batch.test.context.SpringBatchTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.test.context.SpringBootTest;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
/** 대사 배치 Job 테스트: 매칭 1건 + 불일치(입금누락) 1건. */
@SpringBootTest
@SpringBatchTest
class ReconcileJobTest {
@Autowired
private JobLauncherTestUtils jobLauncherTestUtils;
@Autowired
@Qualifier("reconcileJob")
private Job reconcileJob;
@Autowired
private AcquiringService acquiringService;
@Autowired
private ApprovalRepository approvalRepository;
@Autowired
private DepositRepository depositRepository;
@Autowired
private ReconcileResultRepository reconcileRepository;
@Test
void reconcilesThreeWayMatchAndDetectsMismatch() throws Exception {
LocalDate bizDate = LocalDate.of(2026, 7, 21);
// 정상 매칭 : 승인=매입=입금=10000
approvalRepository.save(new Approval("IT-R-0001", "M0000000000001", "123456******7890",
new BigDecimal("10000.00"), "20260721", bizDate));
acquiringService.acceptPurchase(new PurchaseCommand("IT-R-0001", new BigDecimal("10000.00")));
depositRepository.save(new Deposit("IT-R-0001", "M0000000000001",
new BigDecimal("10000.00"), bizDate));
// 불일치 : 매입은 있으나 입금 누락
approvalRepository.save(new Approval("IT-R-0002", "M0000000000001", "123456******7890",
new BigDecimal("5000.00"), "20260721", bizDate));
acquiringService.acceptPurchase(new PurchaseCommand("IT-R-0002", new BigDecimal("5000.00")));
jobLauncherTestUtils.setJob(reconcileJob);
JobParameters params = new JobParametersBuilder()
.addString("bizDate", "20260721")
.addLong("ts", System.currentTimeMillis())
.toJobParameters();
JobExecution exec = jobLauncherTestUtils.launchJob(params);
assertThat(exec.getStatus()).isEqualTo(BatchStatus.COMPLETED);
List<ReconcileResult> results = reconcileRepository.findByBizDate(bizDate);
assertThat(results).hasSize(2);
ReconcileResult matched = reconcileRepository
.findByBizDateAndApprNo(bizDate, "IT-R-0001").orElseThrow();
assertThat(matched.isMatched()).isTrue();
ReconcileResult mismatch = reconcileRepository
.findByBizDateAndApprNo(bizDate, "IT-R-0002").orElseThrow();
assertThat(mismatch.isMatched()).isFalse();
assertThat(mismatch.getReason()).contains("입금 누락");
}
}

View file

@ -0,0 +1,72 @@
package com.klaro.acquiring.app;
import com.klaro.acquiring.acquiring.AcquiringService;
import com.klaro.acquiring.acquiring.dto.PurchaseCommand;
import com.klaro.acquiring.domain.entity.Approval;
import com.klaro.acquiring.domain.entity.Settlement;
import com.klaro.acquiring.persistence.repository.ApprovalRepository;
import com.klaro.acquiring.persistence.repository.SettlementRepository;
import org.junit.jupiter.api.Test;
import org.springframework.batch.core.BatchStatus;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobParameters;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.test.JobLauncherTestUtils;
import org.springframework.batch.test.context.SpringBatchTest;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.test.context.SpringBootTest;
import java.math.BigDecimal;
import java.time.LocalDate;
import static org.assertj.core.api.Assertions.assertThat;
/** 정산 배치 Job 테스트(JobLauncherTestUtils). */
@SpringBootTest
@SpringBatchTest
class SettlementJobTest {
@Autowired
private JobLauncherTestUtils jobLauncherTestUtils;
@Autowired
@Qualifier("settlementJob")
private Job settlementJob;
@Autowired
private AcquiringService acquiringService;
@Autowired
private ApprovalRepository approvalRepository;
@Autowired
private SettlementRepository settlementRepository;
@Test
void runsSettlementJobAndAggregatesNet() throws Exception {
LocalDate bizDate = LocalDate.of(2026, 7, 20);
// 매입 -> 정산 대상
approvalRepository.save(new Approval("IT-S-0001", "M0000000000001", "123456******7890",
new BigDecimal("10000.00"), "20260720", bizDate));
approvalRepository.save(new Approval("IT-S-0002", "M0000000000001", "123456******7890",
new BigDecimal("20000.00"), "20260720", bizDate));
acquiringService.acceptPurchase(new PurchaseCommand("IT-S-0001", new BigDecimal("10000.00")));
acquiringService.acceptPurchase(new PurchaseCommand("IT-S-0002", new BigDecimal("20000.00")));
jobLauncherTestUtils.setJob(settlementJob);
JobParameters params = new JobParametersBuilder()
.addString("bizDate", "20260720")
.addLong("ts", System.currentTimeMillis())
.toJobParameters();
JobExecution exec = jobLauncherTestUtils.launchJob(params);
assertThat(exec.getStatus()).isEqualTo(BatchStatus.COMPLETED);
Settlement s = settlementRepository
.findByMerchIdAndBizDate("M0000000000001", bizDate).orElseThrow();
// gross 30000, MDR 2.3% => 690, VAN 30*2=60, fee 750, net 29250
assertThat(s.getGrossAmount()).isEqualByComparingTo("30000");
assertThat(s.getTotalFee()).isEqualByComparingTo("750");
assertThat(s.getNetAmount()).isEqualByComparingTo("29250");
assertThat(s.getTxnCount()).isEqualTo(2);
// 정산주기 T+2 영업일: 2026-07-20() +2 => 2026-07-22()
assertThat(s.getPayDate()).isEqualTo(LocalDate.of(2026, 7, 22));
}
}