diff --git a/docs/credits.md b/docs/credits.md index 9c35df0..d84b93e 100644 --- a/docs/credits.md +++ b/docs/credits.md @@ -48,7 +48,11 @@ total=available+reserved이며 사용 확정된 양은 제외한다. 원장은 I | 성공 확정 N | 0 | -N | SETTLE | | 실패/취소/만료 해제 N | +N | -N | RELEASE | -지급 출처는 오래된 ID부터 배분하며 배분 관계를 영구 기록한다. 각 출처의 available/reserved/consumed를 구분해 구매분과 무상 지급분을 추적한다. 계정 잔액과 지급 출처·예약·원장이 같은 트랜잭션으로 변경된다. 금액이 아닌 정수 크레딧이며 long 범위 초과와 음수 잔액은 거절한다. +신규 예약은 무료 지급분(SIGNUP·ATTENDANCE·PROMOTION)을 먼저 배분하고 부족한 수량만 유료 지급분(PAYMENT)에서 배분한다. 각 그룹 안에서는 오래된 지급 ID부터 사용한다. 무료 종류별 우선순위는 없고 DB 정렬 후 페이지를 조회하므로 100개를 넘는 지급 건에서도 무료 우선순위를 유지한다. 배분 관계는 영구 기록하며 각 출처의 available/reserved/consumed를 구분해 구매분과 무상 지급분을 추적한다. 계정 잔액과 지급 출처·예약·원장이 같은 트랜잭션으로 변경된다. 금액이 아닌 정수 크레딧이며 long 범위 초과와 음수 잔액은 거절한다. + +#130 배포 이전에 만들어진 예약·사용 내역·원장은 재배분하거나 소급 변경하지 않는다. 기존 예약의 재요청은 최초 수량·기한·출처를 유지하며 성공 확정과 실패/취소/만료 해제는 저장된 배분을 따른다. 예약 이후 무료 지급분이 추가돼도 진행 중인 예약을 옮기지 않는다. 실패 시에는 원래 출처로 복원하므로 유료분을 무료분으로 바꾸거나 이중 복원하지 않는다. + +예를 들어 유료10개 구매 후 무료3개를 지급받았다면 새 이미지 생성3개는 무료에서만 예약한다. 무료1개와 유료10개로 새 이미지 생성3개를 요청하면 무료1개·유료2개를 예약하고, 성공하면 그대로 확정하며 실패하면 각각 원래 출처로 돌려준다. 이 변경은 차감 출처의 순서이며 이미지 3/무료/1 가격·무기한·사용 보류 정책이나 구매 환불 기능을 변경하지 않는다. 예약 상태는 RESERVED에서 SETTLED 또는 RELEASED로 한 번만 이동한다. 확정 후 해제·해제 후 확정은 충돌한다. 동일 resultKey의 확정 재전송은 기존 성공을 반환한다. 중복 예약은 최초 사용량·기한·최종 상태를 유지하며 연장/새 차감을 하지 않는다. 설정이 바뀌어도 기존 예약 수량은 바뀌지 않는다. 다른 요청 키로 같은 usage/target 시도를 다시 예약할 수도 없다. diff --git a/docs/frontend-integration.md b/docs/frontend-integration.md index 157ab58..cabaa0d 100644 --- a/docs/frontend-integration.md +++ b/docs/frontend-integration.md @@ -252,6 +252,12 @@ Refresh는 앱 전체에서 한 번씩 실행되도록 묶는다. StrictMode· - 잔액은 available/reserved/total 및 보류 여부를 구분한다. 최초 온보딩 완료 시 기본3크레딧, 크레딧 무기한. 지급 내역이 없는 기존 완료 계정은0이며 자동 소급하지 않는다. 구매 취소와 생성 실패 예약 해제는 다른 처리다. - 구매 환불 신청·자동 회수/보류 해제 API는 아직 없다. 법정 환불 접수·운영 취소 처리 경로는 별도 준비 중이며 일괄 환불 불가를 확정한 것은 아니다. +## 무료 크레딧 우선 차감 (#130) + +새 이미지 생성/재생성 비용은 서버가 무료(SIGNUP·ATTENDANCE·PROMOTION)부터 예약하고 부족한 수량만 유료(PAYMENT)에서 예약합니다. 같은 그룹 내에서는 먼저 지급한 순서입니다. API 경로·요청·응답과 이미지 3/무료/1 비용은 바뀌지 않습니다. +프론트는 차감 출처를 선택하거나 환불액을 계산하지 않습니다. 생성 요청 후 기존 GET /api/v1/credits/balance의 paidAvailable·paidReserved·freeAvailable·freeReserved를 다시 조회해 표시합니다. 실패/취소/만료 시에는 원래 출처별로 복원하고 성공 시 최초 배분을 확정합니다. +배포 전 예약과 이미 사용한 내역은 소급 변경하지 않으며 예약 후 무료 보상이 추가돼도 진행 중인 예약은 옮기지 않습니다. 환불 신청·PG 환불 API를 추가하는 작업은 아닙니다. 별도 환경변수·DB 마이그레이션 없이 머지·CD 후 새 예약부터 적용됩니다. + ## 필수 문서 동의 (#128) GET /api/v1/legal/documents는 공개 상태와 필수 문서 두 항목의 버전·해시를 반환합니다. diff --git a/docs/payapp-payments.md b/docs/payapp-payments.md index 7f13a0b..bfc39e3 100644 --- a/docs/payapp-payments.md +++ b/docs/payapp-payments.md @@ -5,6 +5,7 @@ [실결제 설정·프론트 복구·운영 검증 안내](payapp-live-readiness.md)를 함께 확인한다. 유상/무료 크레딧 모두 무기한으로 사용하는 정책을 반영해 expiry-policy 기본은 NO_EXPIRY로 변경했다. 결제 모듈은 구매 환불 신청·PG 자동 환불·실물 상품을 포함하지 않는다. 가입 지급과 이미지 과금은 별도 크레딧 기능을 재사용한다. +신규 이미지 예약은 무료 지급분을 먼저 사용하고 부족한 수량만 구매 지급분에서 사용한다(#130). 같은 그룹 안에서는 지급 ID 순서를 유지하며 기존 예약·사용 원장은 소급 변경하지 않는다. 구매별 사용량은 계정 전체 잔액이 아니라 저장된 출처별 예약·확정 배분으로 확인한다. [크레딧 예약 계약](credits.md)을 따른다. ## 공개 계약 diff --git a/src/main/java/com/mongle/backend/domain/credit/CreditGrantRepository.java b/src/main/java/com/mongle/backend/domain/credit/CreditGrantRepository.java index 1dd43bb..1b526ea 100644 --- a/src/main/java/com/mongle/backend/domain/credit/CreditGrantRepository.java +++ b/src/main/java/com/mongle/backend/domain/credit/CreditGrantRepository.java @@ -7,6 +7,8 @@ public interface CreditGrantRepository extends JpaRepository { Optional findFirstByUserIdAndSourceTypeOrderByIdAsc(Long userId, CreditSource sourceType); Optional findBySourceTypeAndSourceHash(CreditSource type, String hash); - @Query("select g from CreditGrant g where g.userId=:userId and g.available>0 order by g.id") + // Apply priority before pagination; retain grant order within free and paid groups. + @Query("select g from CreditGrant g where g.userId=:userId and g.available>0 " + + "order by case when g.sourceType=com.mongle.backend.domain.credit.CreditSource.PAYMENT then 1 else 0 end,g.id") List available(@Param("userId") Long userId, Pageable page); } diff --git a/src/test/java/com/mongle/backend/domain/credit/CreditReservationIntegrationTest.java b/src/test/java/com/mongle/backend/domain/credit/CreditReservationIntegrationTest.java index 76ff902..1831ab6 100644 --- a/src/test/java/com/mongle/backend/domain/credit/CreditReservationIntegrationTest.java +++ b/src/test/java/com/mongle/backend/domain/credit/CreditReservationIntegrationTest.java @@ -5,6 +5,8 @@ import com.mongle.backend.domain.user.repository.UserRepository; import com.mongle.backend.global.error.*; import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; @@ -24,6 +26,9 @@ class CreditReservationIntegrationTest { @Autowired CreditService credits; @Autowired CreditReservationService reservations; @Autowired CreditGrantRepository grants; + @Autowired CreditReservationRepository rows; + @Autowired CreditAllocationRepository allocations; + @Autowired CreditLedgerRepository ledger; @Autowired UserRepository users; @Autowired JdbcTemplate jdbc; @Autowired TransactionTemplate transaction; @@ -32,13 +37,14 @@ class CreditReservationIntegrationTest { @BeforeEach void setup(){ user=newUser(); } Instant deadline(){return authClock.instant().plusSeconds(60);} CreditReservationReceipt reserve(String key){return reservations.reserve(user,key,CreditUsage.DREAM_IMAGE_GENERATION,"attempt:"+key,deadline());} - @Test void reservesOldestGrantsAndPreservesTheirPurchaseSources() { + @Test void reservesFreeGrantsBeforeOlderPurchasesAndPreservesTheirSources() { var paid=credits.grant(user,CreditSource.PAYMENT,"paid:"+user,10); var free=credits.grant(user,CreditSource.PROMOTION,"free:"+user,10); var reservation=reserve("fifo"); assertThat(credits.balance(user)).isEqualTo(new CreditBalance(8,12,20)); - assertThat(grants.findById(paid.grantId()).orElseThrow().getReserved()).isEqualTo(10); - assertThat(grants.findById(free.grantId()).orElseThrow().getReserved()).isEqualTo(2); + assertThat(grants.findById(paid.grantId()).orElseThrow().getReserved()).isEqualTo(2); + assertThat(grants.findById(free.grantId()).orElseThrow().getReserved()).isEqualTo(10); + assertThat(jdbc.queryForList("select grant_id from credit_allocations where reservation_id=? order by id",Long.class,reservation.reservationId())).containsExactly(free.grantId(),paid.grantId()); assertThat(jdbc.queryForList("select amount from credit_allocations where reservation_id=? order by id",Long.class,reservation.reservationId())).containsExactly(10L,2L); assertThat(credits.transactions(user,null,1).items().getFirst().kind()).isEqualTo(CreditLedger.Kind.RESERVE); } @@ -142,6 +148,256 @@ class CreditReservationIntegrationTest { assertThat(credits.balance(user)).isEqualTo(new CreditBalance(30,0,30)); assertThat(jdbc.queryForObject("select count(*) from credit_reservations where user_id=?",Long.class,user)).isZero(); } + + @Test + void freeBalanceCoversTheWholeCostWithoutReservingAnOlderPurchase() { + var paid = credits.grant(user, CreditSource.PAYMENT, "whole-paid:" + user, 10); + var free = credits.grant(user, CreditSource.SIGNUP, "whole-free:" + user, 12); + + var reservation = reserve("whole-free"); + + assertThat(allocationIds(reservation)).containsExactly(free.grantId()); + assertGrant(paid.grantId(), 10, 0, 0); + assertThat(credits.balanceBreakdown(user)).isEqualTo( + new CreditBalanceResponse(10, 12, 22, false, 10, 0, 0, 12)); + } + + @Test + void freeKindsShareGrantOrderRatherThanTakingPriorityByKind() { + var paid = credits.grant(user, CreditSource.PAYMENT, "kind-paid:" + user, 20); + var promotion = credits.grant(user, CreditSource.PROMOTION, "kind-promo:" + user, 2); + var attendance = credits.grant(user, CreditSource.ATTENDANCE, "kind-attend:" + user, 3); + var signup = credits.grant(user, CreditSource.SIGNUP, "kind-signup:" + user, 4); + var latest = credits.grant(user, CreditSource.PROMOTION, "kind-latest:" + user, 5); + + var reservation = reserve("free-kinds"); + + assertThat(allocationIds(reservation)).containsExactly( + promotion.grantId(), attendance.grantId(), signup.grantId(), latest.grantId()); + assertThat(allocationAmounts(reservation)).containsExactly(2L, 3L, 4L, 3L); + assertGrant(paid.grantId(), 20, 0, 0); + assertGrant(latest.grantId(), 2, 3, 0); + } + + @Test + void paidRemainderUsesOlderPurchasesFirst() { + var older = credits.grant(user, CreditSource.PAYMENT, "paid-first:" + user, 5); + var free = credits.grant(user, CreditSource.ATTENDANCE, "paid-free:" + user, 4); + var newer = credits.grant(user, CreditSource.PAYMENT, "paid-last:" + user, 10); + + var reservation = reserve("paid-order"); + + assertThat(allocationIds(reservation)).containsExactly(free.grantId(), older.grantId(), newer.grantId()); + assertThat(allocationAmounts(reservation)).containsExactly(4L, 5L, 3L); + assertThat(credits.balanceBreakdown(user)).isEqualTo( + new CreditBalanceResponse(7, 12, 19, false, 7, 8, 0, 4)); + } + + @ParameterizedTest + @ValueSource(strings = {"SETTLE", "FAILURE", "CANCELLED", "EXPIRED"}) + void mixedReservationFinishesUsingItsOriginalSources(String outcome) { + var paid = credits.grant(user, CreditSource.PAYMENT, "finish-paid:" + user, 10); + var free = credits.grant(user, CreditSource.PROMOTION, "finish-free:" + user, 5); + var reservation = reserve("mixed-finish"); + var ids = allocationIds(reservation); + var amounts = allocationAmounts(reservation); + var later = credits.grant(user, CreditSource.ATTENDANCE, "finish-later:" + user, 20); + + if (outcome.equals("SETTLE")) { + var settled = reservations.settle(user, reservation.reservationId(), "mixed-result"); + assertThat(reservations.settle(user, reservation.reservationId(), "mixed-result")).isEqualTo(settled); + assertGrant(paid.grantId(), 3, 0, 7); + assertGrant(free.grantId(), 0, 0, 5); + assertThat(credits.balanceBreakdown(user)).isEqualTo( + new CreditBalanceResponse(23, 0, 23, false, 3, 0, 20, 0)); + } else { + var reason = CreditReservation.ReleaseReason.valueOf(outcome); + if (reason == CreditReservation.ReleaseReason.EXPIRED) { + jdbc.update("update credit_reservations set expires_at=? where id=?", + LocalDateTime.ofInstant(authClock.instant().minusSeconds(1), ZoneOffset.UTC) + .format(java.time.format.DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")), + reservation.reservationId()); + assertThat(reservations.expire(user, reservation.reservationId())).isTrue(); + assertThat(reservations.expire(user, reservation.reservationId())).isFalse(); + } else { + reservations.release(user, reservation.reservationId(), reason); + } + var released = reservations.get(user, reservation.reservationId()); + assertThat(reservations.release(user, reservation.reservationId(), reason)).isEqualTo(released); + assertThat(released.releaseReason()).isEqualTo(reason); + assertGrant(paid.grantId(), 10, 0, 0); + assertGrant(free.grantId(), 5, 0, 0); + assertThat(credits.balanceBreakdown(user)).isEqualTo( + new CreditBalanceResponse(35, 0, 35, false, 10, 0, 25, 0)); + } + assertGrant(later.grantId(), 20, 0, 0); + assertThat(allocationIds(reservation)).isEqualTo(ids); + assertThat(allocationAmounts(reservation)).isEqualTo(amounts); + assertThat(jdbc.queryForObject("select count(*) from credit_ledger where reservation_id=? and kind in ('SETTLE','RELEASE')", + Long.class, reservation.reservationId())).isEqualTo(1); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void freePrioritySurvivesAllocationPagesAndKeepsAllSources(boolean settle) { + var olderPaid = credits.grant(user, CreditSource.PAYMENT, "page-old:" + user, 5); + var freeIds = new ArrayList(); + var kinds = List.of(CreditSource.SIGNUP, CreditSource.PROMOTION, CreditSource.ATTENDANCE); + for (int i = 0; i < 103; i++) { + freeIds.add(credits.grant(user, kinds.get(i % kinds.size()), "page-free:" + user + ":" + i, 1).grantId()); + } + var newerPaid = credits.grant(user, CreditSource.PAYMENT, "page-new:" + user, 20); + var policy = new CreditProperties(true, CreditProperties.ExpiryPolicy.NO_EXPIRY, 3L, 115L, null, null, null); + var service = new CreditReservationService(credits, rows, grants, allocations, ledger, policy, authClock); + var reservation = transaction.execute(s -> service.reserve(user, "multi-page", CreditUsage.DREAM_IMAGE_GENERATION, + "multi-page", authClock.instant().plusSeconds(300))); + var expectedIds = new ArrayList<>(freeIds); + expectedIds.add(olderPaid.grantId()); + expectedIds.add(newerPaid.grantId()); + assertThat(allocationIds(reservation)).containsExactlyElementsOf(expectedIds); + assertThat(allocationAmounts(reservation)).hasSize(105).endsWith(5L, 7L); + + if (settle) { + reservations.settle(user, reservation.reservationId(), "page-result"); + for (var id : freeIds) assertGrant(id, 0, 0, 1); + assertGrant(olderPaid.grantId(), 0, 0, 5); + assertGrant(newerPaid.grantId(), 13, 0, 7); + assertThat(credits.balance(user)).isEqualTo(new CreditBalance(13, 0, 13)); + } else { + reservations.release(user, reservation.reservationId(), CreditReservation.ReleaseReason.FAILURE); + for (var id : freeIds) assertGrant(id, 1, 0, 0); + assertGrant(olderPaid.grantId(), 5, 0, 0); + assertGrant(newerPaid.grantId(), 20, 0, 0); + assertThat(credits.balance(user)).isEqualTo(new CreditBalance(128, 0, 128)); + } + assertThat(allocationIds(reservation)).containsExactlyElementsOf(expectedIds); + } + + @Test + void duplicateRequestDoesNotRedistributeAnExistingReservationAfterNewFreeCredit() { + var paid = credits.grant(user, CreditSource.PAYMENT, "duplicate-paid:" + user, 20); + var free = credits.grant(user, CreditSource.SIGNUP, "duplicate-free:" + user, 3); + var reservation = reserve("duplicate-sources"); + var later = credits.grant(user, CreditSource.PROMOTION, "duplicate-later:" + user, 20); + var history = jdbc.queryForList("select * from credit_ledger where user_id=? order by id", user); + + assertThat(reservations.reserve(user, "duplicate-sources", CreditUsage.DREAM_IMAGE_GENERATION, + "attempt:duplicate-sources", deadline().plusSeconds(100))).isEqualTo(reservation); + + assertThat(allocationIds(reservation)).containsExactly(free.grantId(), paid.grantId()); + assertThat(allocationAmounts(reservation)).containsExactly(3L, 9L); + assertGrant(later.grantId(), 20, 0, 0); + assertThat(jdbc.queryForList("select * from credit_ledger where user_id=? order by id", user)).isEqualTo(history); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + void preexistingPaidFirstReservationIsNotReallocated(boolean settle) { + var paid = credits.grant(user, CreditSource.PAYMENT, "legacy-paid:" + user, 10); + var free = credits.grant(user, CreditSource.PROMOTION, "legacy-free:" + user, 10); + // Persist the old paid-first allocation, as it could exist when this version is deployed. + var reservation = transaction.execute(s -> { + credits.requireUser(user, true); + var account = credits.account(user); + account.reserve(12); + // Match reserve(): DATETIME(6) cannot retain Linux Clock nanoseconds. + var row = rows.saveAndFlush(CreditReservation.create(user, "legacy", CreditUsage.DREAM_IMAGE_GENERATION, + "attempt:legacy", 12, deadline().truncatedTo(java.time.temporal.ChronoUnit.MICROS))); + var paidGrant = grants.findById(paid.grantId()).orElseThrow(); + var freeGrant = grants.findById(free.grantId()).orElseThrow(); + paidGrant.reserve(10); + freeGrant.reserve(2); + allocations.save(CreditAllocation.create(row, paidGrant, 10)); + allocations.save(CreditAllocation.create(row, freeGrant, 2)); + ledger.saveAndFlush(CreditLedger.entry(account, CreditLedger.Kind.RESERVE, null, row.getId(), + null, row.getUsage(), -12, 12)); + return CreditReservationReceipt.from(row); + }); + var history = jdbc.queryForList("select * from credit_ledger where user_id=? order by id", user); + assertThat(reserve("legacy")).isEqualTo(reservation); + assertThat(jdbc.queryForList("select * from credit_ledger where user_id=? order by id", user)).isEqualTo(history); + + if (settle) { + reservations.settle(user, reservation.reservationId(), "legacy-result"); + assertGrant(paid.grantId(), 0, 0, 10); + assertGrant(free.grantId(), 8, 0, 2); + } else { + reservations.release(user, reservation.reservationId(), CreditReservation.ReleaseReason.FAILURE); + assertGrant(paid.grantId(), 10, 0, 0); + assertGrant(free.grantId(), 10, 0, 0); + } + assertThat(allocationIds(reservation)).containsExactly(paid.grantId(), free.grantId()); + assertThat(allocationAmounts(reservation)).containsExactly(10L, 2L); + } + + @Test + void concurrentMixedReservationsApplyEachSourceOnlyOnce() throws Exception { + var paid = credits.grant(user, CreditSource.PAYMENT, "race-mixed-paid:" + user, 12); + var free = credits.grant(user, CreditSource.SIGNUP, "race-mixed-free:" + user, 12); + + assertThat(race(() -> reserve("mixed-race-a"), () -> reserve("mixed-race-b"))).containsOnly("OK"); + + assertGrant(paid.grantId(), 0, 12, 0); + assertGrant(free.grantId(), 0, 12, 0); + var first = rows.findByUserIdAndRequestHash(user, CreditRules.hash("mixed-race-a")).orElseThrow(); + var second = rows.findByUserIdAndRequestHash(user, CreditRules.hash("mixed-race-b")).orElseThrow(); + assertThat(List.of(allocationIds(CreditReservationReceipt.from(first)), allocationIds(CreditReservationReceipt.from(second)))) + .containsExactlyInAnyOrder(List.of(free.grantId()), List.of(paid.grantId())); + assertThat(credits.balanceBreakdown(user)).isEqualTo( + new CreditBalanceResponse(0, 24, 24, false, 0, 12, 0, 12)); + } + + @Test + void mixedReservationRollbackRestoresBothSourcesAndLeavesNoAllocation() { + var paid = credits.grant(user, CreditSource.PAYMENT, "mixed-rollback-paid:" + user, 10); + var free = credits.grant(user, CreditSource.ATTENDANCE, "mixed-rollback-free:" + user, 5); + var history = jdbc.queryForList("select * from credit_ledger where user_id=? order by id", user); + + assertThatThrownBy(() -> transaction.execute(s -> { + reserve("mixed-rollback"); + throw new IllegalStateException("시도 저장 실패"); + })).isInstanceOf(IllegalStateException.class); + + assertGrant(paid.grantId(), 10, 0, 0); + assertGrant(free.grantId(), 5, 0, 0); + assertThat(jdbc.queryForObject("select count(*) from credit_allocations a join credit_reservations r on r.id=a.reservation_id where r.user_id=?", + Long.class, user)).isZero(); + assertThat(jdbc.queryForList("select * from credit_ledger where user_id=? order by id", user)).isEqualTo(history); + assertThat(credits.balance(user)).isEqualTo(new CreditBalance(15, 0, 15)); + } + + @Test + void insufficientMixedBalanceAndPaymentHoldDoNotAllocateEitherSource() { + var paid = credits.grant(user, CreditSource.PAYMENT, "reject-paid:" + user, 8); + var free = credits.grant(user, CreditSource.SIGNUP, "reject-free:" + user, 3); + error(() -> reserve("reject-insufficient"), CreditErrorCode.INSUFFICIENT); + credits.grant(user, CreditSource.ATTENDANCE, "reject-extra:" + user, 10); + credits.holdPayment(user, "reject-paid:" + user); + error(() -> reserve("reject-hold"), CreditErrorCode.PAYMENT_REVIEW); + + assertGrant(paid.grantId(), 8, 0, 0); + assertGrant(free.grantId(), 3, 0, 0); + assertThat(credits.balanceBreakdown(user)).isEqualTo( + new CreditBalanceResponse(21, 0, 21, true, 8, 0, 13, 0)); + assertThat(jdbc.queryForObject("select count(*) from credit_reservations where user_id=?", Long.class, user)).isZero(); + } + + List allocationIds(CreditReservationReceipt reservation) { + return jdbc.queryForList("select grant_id from credit_allocations where reservation_id=? order by id", + Long.class, reservation.reservationId()); + } + + List allocationAmounts(CreditReservationReceipt reservation) { + return jdbc.queryForList("select amount from credit_allocations where reservation_id=? order by id", + Long.class, reservation.reservationId()); + } + + void assertGrant(Long id, long available, long reserved, long consumed) { + var grant = grants.findById(id).orElseThrow(); + assertThat(grant.getAvailable()).as("grant %s available", id).isEqualTo(available); + assertThat(grant.getReserved()).as("grant %s reserved", id).isEqualTo(reserved); + assertThat(grant.getConsumed()).as("grant %s consumed", id).isEqualTo(consumed); + } Long newUser(){return users.saveAndFlush(User.create(UUID.randomUUID()+"@reserve.test","예약")).getId();} void error(Runnable r,ErrorCode e){assertThatThrownBy(r::run).isInstanceOfSatisfying(BusinessException.class,x->assertThat(x.getErrorCode()).isEqualTo(e));} List race(Runnable a,Runnable b) throws Exception {