package com.osglab.account.features.credits import com.osglab.account.features.credits.domain.CreditCostCalculator import com.osglab.account.features.credits.domain.CreditConflict import com.osglab.account.features.credits.domain.CreditRateVersion import com.osglab.account.features.credits.domain.InsufficientCredits import com.osglab.account.features.credits.domain.InvalidCreditRequest import com.osglab.account.features.credits.domain.LedgerEntryType import com.osglab.account.features.credits.domain.ReservationStatus import com.osglab.account.features.credits.domain.ReservationStateRules import com.osglab.account.features.credits.domain.UsageKind import com.osglab.account.features.credits.domain.UsageMeasurement import com.osglab.account.features.credits.domain.externalIdempotencyKey import com.osglab.account.features.credits.services.CreditService import com.osglab.account.features.credits.services.ReferralRewardConfig import com.osglab.account.features.credits.services.signupTrialIdempotencyKey import com.osglab.account.features.referrals.domain.ReferralBinding import com.osglab.account.features.referrals.domain.ReferralCampaign import com.osglab.account.features.referrals.domain.ReferralCampaignBudget import com.osglab.account.features.referrals.domain.ReferralRewardStatus import io.kotest.core.spec.style.FunSpec import io.kotest.assertions.throwables.shouldThrow import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.longs.shouldBeExactly import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import io.kotest.matchers.types.shouldBeInstanceOf import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import java.time.Clock import java.time.Instant import java.time.ZoneOffset import java.util.UUID import kotlin.random.Random class CreditServiceTest : FunSpec({ val now = Instant.parse("2026-08-15T00:00:00Z") test("ASR and LLM rates round each billed dimension upward") { CreditCostCalculator.calculate( asrRate(now), UsageMeasurement.Asr(durationMillis = 101), ) shouldBeExactly 2 CreditCostCalculator.calculate( llmRate(now), UsageMeasurement.Llm(inputTokens = 1001, outputTokens = 1), ) shouldBeExactly 4 } test("smaller production unit bills each started interval") { val productionAsr = asrRate(now).copy(asrMillisDenominator = 3_000) CreditCostCalculator.calculate( productionAsr, UsageMeasurement.Asr(durationMillis = 1), ) shouldBeExactly 1 CreditCostCalculator.calculate( productionAsr, UsageMeasurement.Asr(durationMillis = 3_000), ) shouldBeExactly 1 CreditCostCalculator.calculate( productionAsr, UsageMeasurement.Asr(durationMillis = 3_001), ) shouldBeExactly 2 val productionLlm = llmRate(now).copy( inputCreditsNumerator = 1, inputTokensDenominator = 1_000, outputCreditsNumerator = 1, outputTokensDenominator = 400, ) CreditCostCalculator.calculate( productionLlm, UsageMeasurement.Llm(inputTokens = 1_001, outputTokens = 401), ) shouldBeExactly 4 } test("cost calculation avoids intermediate overflow and rejects an unrepresentable result") { CreditCostCalculator.calculate( asrRate(now).copy( asrCreditsNumerator = 2, asrMillisDenominator = 2, ), UsageMeasurement.Asr(Long.MAX_VALUE), ) shouldBeExactly Long.MAX_VALUE shouldThrow { CreditCostCalculator.calculate( asrRate(now).copy( asrCreditsNumerator = 2, asrMillisDenominator = 1, ), UsageMeasurement.Asr(Long.MAX_VALUE), ) } } test("signup grant is idempotent") { val store = storeWithRates(now) val service = service(store, now) val userId = UUID.randomUUID() service.grantSignupTrial(userId, 100, "signup-key-001") service.grantSignupTrial(userId, 100, "signup-key-001") store.balance(userId) shouldBeExactly 100 store.ledger.filter { it.type == LedgerEntryType.SIGNUP_TRIAL } shouldHaveSize 1 } test("signup trial ledger is the authoritative account-level claim record") { val store = storeWithRates(now) val service = service(store, now) val userId = UUID.randomUUID() service.hasSignupTrial(userId) shouldBe false service.grantSignupTrial(userId, 100, signupTrialIdempotencyKey(userId)) service.hasSignupTrial(userId) shouldBe true } test("manual grant appends one linked audit and ledger entry") { val store = storeWithRates(now) val service = service(store, now) val userId = UUID.randomUUID() store.registeredUsers += userId val result = service.grantManual( operatorId = TEST_OPERATOR_ID, userId = userId, credits = 250, reason = "Customer support adjustment", requestId = "support-ticket-1042", idempotencyKey = "manual-grant-key-001", ) result.replayed shouldBe false result.balanceAfter shouldBeExactly 250 store.balance(userId) shouldBeExactly 250 store.manualGrants.single() shouldBe result.grant store.adminAudits.single().id shouldBe result.grant.auditLogId store.ledger.single { it.type == LedgerEntryType.MANUAL_GRANT }.let { ledger -> ledger.referenceId shouldBe result.grant.id ledger.id shouldBe result.grant.ledgerEntryId } } test("manual grant replay is stable and rejects changed parameters") { val store = storeWithRates(now) val service = service(store, now) val userId = UUID.randomUUID() store.registeredUsers += userId val first = service.grantManual( TEST_OPERATOR_ID, userId, 100, "Retention credit", "support-ticket-1043", "manual-grant-key-002", ) val replay = service.grantManual( TEST_OPERATOR_ID, userId, 100, "Retention credit", "support-ticket-1043", "manual-grant-key-002", ) replay.grant shouldBe first.grant replay.replayed shouldBe true shouldThrow { service.grantManual( TEST_OPERATOR_ID, userId, 101, "Retention credit", "support-ticket-1043", "manual-grant-key-002", ) } store.balance(userId) shouldBeExactly 100 store.manualGrants shouldHaveSize 1 store.ledger.filter { it.type == LedgerEntryType.MANUAL_GRANT } shouldHaveSize 1 } test("manual grant validates positive amount and rolls back an audit failure") { val store = storeWithRates(now) val service = service(store, now) val userId = UUID.randomUUID() store.registeredUsers += userId shouldThrow { service.grantManual( TEST_OPERATOR_ID, userId, 0, "Invalid adjustment", "support-ticket-1044", "manual-grant-key-003", ) } store.failNextManualGrantInsert = true shouldThrow { service.grantManual( TEST_OPERATOR_ID, userId, 50, "Rollback adjustment", "support-ticket-1045", "manual-grant-key-004", ) } store.balance(userId) shouldBeExactly 0 store.manualGrants shouldHaveSize 0 store.adminAudits shouldHaveSize 0 store.ledger.filter { it.type == LedgerEntryType.MANUAL_GRANT } shouldHaveSize 0 } test("concurrent manual grant replay credits exactly once") { val store = storeWithRates(now) val service = service(store, now) val userId = UUID.randomUUID() store.registeredUsers += userId val results = coroutineScope { List(8) { async(Dispatchers.Default) { service.grantManual( TEST_OPERATOR_ID, userId, 75, "Concurrent adjustment", "support-ticket-1046", "manual-grant-key-005", ) } }.awaitAll() } results.map { it.grant.id }.distinct() shouldHaveSize 1 store.balance(userId) shouldBeExactly 75 store.manualGrants shouldHaveSize 1 store.ledger.filter { it.type == LedgerEntryType.MANUAL_GRANT } shouldHaveSize 1 } test("manual grant supports the maximum balance and rejects overflow") { val store = storeWithRates(now) val service = service(store, now) val userId = UUID.randomUUID() store.registeredUsers += userId service.grantManual( TEST_OPERATOR_ID, userId, Long.MAX_VALUE, "Maximum supported adjustment", "support-ticket-1047", "manual-grant-key-006", ) shouldThrow { service.grantManual( TEST_OPERATOR_ID, userId, 1, "Overflow adjustment", "support-ticket-1048", "manual-grant-key-007", ) } store.balance(userId) shouldBeExactly Long.MAX_VALUE store.manualGrants shouldHaveSize 1 } test("balance overflow rolls back without appending a ledger entry") { val store = storeWithRates(now) val service = service(store, now) val userId = UUID.randomUUID() service.grantSignupTrial(userId, Long.MAX_VALUE, "signup-max-key") shouldThrow { service.grantSignupTrial(userId, 1, "signup-overflow-key") } store.balance(userId) shouldBeExactly Long.MAX_VALUE store.ledger.filter { it.userId == userId } shouldHaveSize 1 } test("concurrent reservations cannot make the account negative") { val store = storeWithRates(now) val service = service(store, now) val userId = UUID.randomUUID() service.grantSignupTrial(userId, 100, "signup-key-002") val results = coroutineScope { listOf("reserve-key-001", "reserve-key-002").map { key -> async(Dispatchers.Default) { runCatching { service.reserve( userId = userId, provider = "asr-provider", model = "asr-model", estimatedUsage = UsageMeasurement.Asr(6_000), managedCall = true, idempotencyKey = key, ) } } }.awaitAll() } results.count(Result<*>::isSuccess) shouldBe 1 results.single(Result<*>::isFailure).exceptionOrNull() .shouldBeInstanceOf() store.balance(userId) shouldBeExactly 40 } test("first positive managed settlement rewards both users exactly once") { val store = storeWithRates(now) val service = service(store, now) val inviter = UUID.randomUUID() val invitee = UUID.randomUUID() val binding = ReferralBinding( id = UUID.randomUUID(), inviterUserId = inviter, inviteeUserId = invitee, codeId = UUID.randomUUID(), boundAt = now, rewardedAt = null, rewardSettlementId = null, ) store.inTransaction { it.referrals.insertBindingIfAbsent(binding) } service.grantSignupTrial(invitee, 100, "signup-key-003") val reservation = service.reserve( userId = invitee, provider = "asr-provider", model = "asr-model", estimatedUsage = UsageMeasurement.Asr(1_000), managedCall = true, idempotencyKey = "reserve-key-003", ) val first = service.settle( invitee, reservation.id, UsageMeasurement.Asr(500), "settle-key-003", ) val retried = service.settle( invitee, reservation.id, UsageMeasurement.Asr(500), "settle-key-003", ) first.status shouldBe ReservationStatus.SETTLED retried shouldBe first store.balance(invitee) shouldBeExactly 125 store.balance(inviter) shouldBeExactly 30 store.ledger.filter { it.type == LedgerEntryType.REFERRAL_INVITEE || it.type == LedgerEntryType.REFERRAL_INVITER } shouldHaveSize 2 store.usageRecords shouldHaveSize 1 store.usageRecords.single().rateVersionId shouldBe reservation.rateVersionId store.bindings.getValue(invitee).rewardSettlementId shouldBe reservation.id } test("zero-cost managed settlement does not consume first valid referral reward") { val store = storeWithRates(now) val service = service(store, now) val inviter = UUID.randomUUID() val invitee = UUID.randomUUID() store.inTransaction { it.referrals.insertBindingIfAbsent( ReferralBinding( id = UUID.randomUUID(), inviterUserId = inviter, inviteeUserId = invitee, codeId = UUID.randomUUID(), boundAt = now, rewardedAt = null, rewardSettlementId = null, ), ) } service.grantSignupTrial(invitee, 100, "zero-signup-key") val zeroCost = service.reserve( invitee, "asr-provider", "asr-model", UsageMeasurement.Asr(1_000), managedCall = true, idempotencyKey = "zero-reserve-key", ) service.settle( invitee, zeroCost.id, UsageMeasurement.Asr(0), "zero-settle-key", ) store.bindings.getValue(invitee).rewardStatus shouldBe ReferralRewardStatus.PENDING val qualifying = service.reserve( invitee, "asr-provider", "asr-model", UsageMeasurement.Asr(1_000), managedCall = true, idempotencyKey = "valid-reserve-key", ) service.settle( invitee, qualifying.id, UsageMeasurement.Asr(1), "valid-settle-key", ) store.bindings.getValue(invitee).rewardSettlementId shouldBe qualifying.id store.ledger.count { it.type == LedgerEntryType.REFERRAL_INVITER || it.type == LedgerEntryType.REFERRAL_INVITEE } shouldBe 2 } test("concurrent settlement replay grants both referral sides only once") { val store = storeWithRates(now) val service = service(store, now) val inviter = UUID.randomUUID() val invitee = UUID.randomUUID() store.inTransaction { it.referrals.insertBindingIfAbsent( ReferralBinding( id = UUID.randomUUID(), inviterUserId = inviter, inviteeUserId = invitee, codeId = UUID.randomUUID(), boundAt = now, rewardedAt = null, rewardSettlementId = null, ), ) } service.grantSignupTrial(invitee, 100, "concurrent-signup-key") val reservation = service.reserve( userId = invitee, provider = "asr-provider", model = "asr-model", estimatedUsage = UsageMeasurement.Asr(1_000), managedCall = true, idempotencyKey = "concurrent-reserve-key", ) val results = coroutineScope { List(8) { async(Dispatchers.Default) { service.settle( userId = invitee, reservationId = reservation.id, actualUsage = UsageMeasurement.Asr(500), idempotencyKey = "concurrent-settle-key", ) } }.awaitAll() } results.distinct() shouldHaveSize 1 store.usageRecords shouldHaveSize 1 store.ledger.count { it.type == LedgerEntryType.REFERRAL_INVITEE || it.type == LedgerEntryType.REFERRAL_INVITER } shouldBe 2 store.balance(inviter) shouldBeExactly 30 store.balance(invitee) shouldBeExactly 125 } test("ledger projection remains non-negative across randomized terminal operations") { val store = storeWithRates(now) val service = service(store, now) val userId = UUID.randomUUID() service.grantSignupTrial(userId, 20_000, "property-signup-key") val random = Random(42) repeat(100) { index -> val estimate = random.nextLong(1, 5_000) val reservation = service.reserve( userId, "asr-provider", "asr-model", UsageMeasurement.Asr(estimate), managedCall = false, idempotencyKey = "property-reserve-$index", ) if (index % 3 == 0) { service.release(userId, reservation.id, "property-release-$index") } else { val actual = random.nextLong(0, estimate + 1) service.settle( userId, reservation.id, UsageMeasurement.Asr(actual), "property-settle-$index", ) if (index % 5 == 0) { service.refund(userId, reservation.id, "property-refund-$index") } } } var projection = 0L store.ledger.filter { it.userId == userId }.forEach { entry -> projection = Math.addExact(projection, entry.amountDelta) entry.balanceAfter shouldBeExactly projection (projection >= 0) shouldBe true } store.balance(userId) shouldBeExactly projection } test("campaign cap is consumed once and later qualification is not rewarded") { val store = storeWithRates(now) val service = service(store, now) val campaignId = UUID.randomUUID() store.campaigns[campaignId] = ReferralCampaign( id = campaignId, name = "One reward", startsAt = now.minusSeconds(60), endsAt = null, bindingWindowSeconds = 604_800, inviterRewardCredits = 7, inviteeRewardCredits = 5, maxRewardedBindings = 1, budgetCredits = 12, enabled = true, ) store.campaignBudgets[campaignId] = ReferralCampaignBudget(campaignId, 0, 0, now) val inviter = UUID.randomUUID() val invitees = List(2) { UUID.randomUUID() } invitees.forEach { invitee -> store.inTransaction { it.referrals.insertBindingIfAbsent( ReferralBinding( id = UUID.randomUUID(), inviterUserId = inviter, inviteeUserId = invitee, codeId = UUID.randomUUID(), boundAt = now, rewardedAt = null, rewardSettlementId = null, campaignId = campaignId, ), ) } service.grantSignupTrial(invitee, 100, "campaign-signup-$invitee") val reservation = service.reserve( invitee, "asr-provider", "asr-model", UsageMeasurement.Asr(1_000), managedCall = true, idempotencyKey = "campaign-reserve-$invitee", ) service.settle( invitee, reservation.id, UsageMeasurement.Asr(500), "campaign-settle-$invitee", ) } store.ledger.count { it.type == LedgerEntryType.REFERRAL_INVITER || it.type == LedgerEntryType.REFERRAL_INVITEE } shouldBe 2 store.campaignBudgets.getValue(campaignId).rewardedBindings shouldBeExactly 1 store.bindings.getValue(invitees[1]).rewardStatus shouldBe ReferralRewardStatus.INELIGIBLE_BUDGET } test("exhausted campaign counters fail closed without partial rewards") { val store = storeWithRates(now) val service = service(store, now) val campaignId = UUID.randomUUID() store.campaigns[campaignId] = ReferralCampaign( id = campaignId, name = "Exhausted", startsAt = now.minusSeconds(60), endsAt = null, bindingWindowSeconds = 604_800, inviterRewardCredits = 7, inviteeRewardCredits = 5, maxRewardedBindings = null, budgetCredits = null, enabled = true, ) store.campaignBudgets[campaignId] = ReferralCampaignBudget( campaignId, Long.MAX_VALUE, Long.MAX_VALUE, now, ) val inviter = UUID.randomUUID() val invitee = UUID.randomUUID() store.inTransaction { it.referrals.insertBindingIfAbsent( ReferralBinding( id = UUID.randomUUID(), inviterUserId = inviter, inviteeUserId = invitee, codeId = UUID.randomUUID(), boundAt = now, rewardedAt = null, rewardSettlementId = null, campaignId = campaignId, ), ) } service.grantSignupTrial(invitee, 100, "exhausted-signup-key") val reservation = service.reserve( invitee, "asr-provider", "asr-model", UsageMeasurement.Asr(1_000), managedCall = true, idempotencyKey = "exhausted-reserve-key", ) service.settle( invitee, reservation.id, UsageMeasurement.Asr(500), "exhausted-settle-key", ) store.bindings.getValue(invitee).rewardStatus shouldBe ReferralRewardStatus.INELIGIBLE_BUDGET store.ledger.none { it.type == LedgerEntryType.REFERRAL_INVITER || it.type == LedgerEntryType.REFERRAL_INVITEE } shouldBe true } test("account summary reports settled usage separately from remaining credits") { val store = storeWithRates(now) val service = service(store, now) val userId = UUID.randomUUID() service.grantSignupTrial(userId, 100, "summary-signup-key") val reservation = service.reserve( userId, "asr-provider", "asr-model", UsageMeasurement.Asr(1_000), managedCall = false, idempotencyKey = "summary-reserve-key", ) val reservedSummary = service.getAccountSummary(userId) reservedSummary.account.balance shouldBeExactly 90 reservedSummary.lifetimeUsed shouldBeExactly 0 service.settle( userId, reservation.id, UsageMeasurement.Asr(500), "summary-settle-key", ) val summary = service.getAccountSummary(userId) summary.account.balance shouldBeExactly 95 summary.lifetimeUsed shouldBeExactly 5 service.refund(userId, reservation.id, "summary-refund-key") val refundedSummary = service.getAccountSummary(userId) refundedSummary.account.balance shouldBeExactly 100 refundedSummary.lifetimeUsed shouldBeExactly 0 } test("release and refund restore only the corresponding debit") { val store = storeWithRates(now) val service = service(store, now) val userId = UUID.randomUUID() service.grantSignupTrial(userId, 100, "signup-key-004") val released = service.reserve( userId, "asr-provider", "asr-model", UsageMeasurement.Asr(1_000), managedCall = false, idempotencyKey = "reserve-key-004", ) service.release(userId, released.id, "release-key-004") service.release(userId, released.id, "release-key-004") val settled = service.reserve( userId, "asr-provider", "asr-model", UsageMeasurement.Asr(1_000), managedCall = false, idempotencyKey = "reserve-key-005", ) service.settle(userId, settled.id, UsageMeasurement.Asr(500), "settle-key-005") service.refund(userId, settled.id, "refund-key-005") service.refund(userId, settled.id, "refund-key-005") store.balance(userId) shouldBeExactly 100 store.ledger.filter { it.type == LedgerEntryType.USAGE_RELEASE } shouldHaveSize 1 store.ledger.filter { it.type == LedgerEntryType.USAGE_REFUND } shouldHaveSize 1 } test("settlement replay rejects different actual usage") { val store = storeWithRates(now) val service = service(store, now) val userId = UUID.randomUUID() service.grantSignupTrial(userId, 100, "signup-key-006") val reservation = service.reserve( userId, "asr-provider", "asr-model", UsageMeasurement.Asr(1_000), managedCall = false, idempotencyKey = "reserve-key-006", ) service.settle(userId, reservation.id, UsageMeasurement.Asr(500), "settle-key-006") shouldThrow { service.settle(userId, reservation.id, UsageMeasurement.Asr(600), "settle-key-006") } } test("public idempotency values cannot occupy the internal reward namespace") { externalIdempotencyKey("internal:referral:known-binding:invitee") shouldNotBe "internal:referral:known-binding:invitee" } test("reservation state rules allow only settle release and refund transitions") { ReservationStateRules.canTransition( ReservationStatus.RESERVED, ReservationStatus.SETTLED, ) shouldBe true ReservationStateRules.canTransition( ReservationStatus.RESERVED, ReservationStatus.RELEASED, ) shouldBe true ReservationStateRules.canTransition( ReservationStatus.SETTLED, ReservationStatus.REFUNDED, ) shouldBe true ReservationStatus.entries.forEach { target -> ReservationStateRules.canTransition( ReservationStatus.REFUNDED, target, ) shouldBe false } ReservationStateRules.canTransition( ReservationStatus.RELEASED, ReservationStatus.SETTLED, ) shouldBe false } }) private fun service(store: TestBillingStore, now: Instant) = CreditService( transactions = store, referralRewards = ReferralRewardConfig(inviterCredits = 30, inviteeCredits = 30), clock = Clock.fixed(now, ZoneOffset.UTC), ) private fun storeWithRates(now: Instant) = TestBillingStore().also { val asr = asrRate(now) val llm = llmRate(now) it.rates[asr.id] = asr it.rates[llm.id] = llm } private fun asrRate(now: Instant) = CreditRateVersion( id = UUID.nameUUIDFromBytes("asr-rate".toByteArray()), kind = UsageKind.ASR, provider = "asr-provider", model = "asr-model", effectiveFrom = now.minusSeconds(60), effectiveUntil = null, asrCreditsNumerator = 1, asrMillisDenominator = 100, inputCreditsNumerator = null, inputTokensDenominator = null, outputCreditsNumerator = null, outputTokensDenominator = null, ) private fun llmRate(now: Instant) = CreditRateVersion( id = UUID.nameUUIDFromBytes("llm-rate".toByteArray()), kind = UsageKind.LLM, provider = "llm-provider", model = "llm-model", effectiveFrom = now.minusSeconds(60), effectiveUntil = null, asrCreditsNumerator = null, asrMillisDenominator = null, inputCreditsNumerator = 2, inputTokensDenominator = 1_000, outputCreditsNumerator = 3, outputTokensDenominator = 1_000, ) private val TEST_OPERATOR_ID = UUID.fromString("11111111-1111-1111-1111-111111111111")