d0abe27623
Introduce versioned official content workflows and privacy-safe keyboard analytics, while preventing repeat DeviceCheck sign-ins from incorrectly restricting eligible accounts.
810 lines
29 KiB
Kotlin
810 lines
29 KiB
Kotlin
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<InvalidCreditRequest> {
|
|
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<CreditConflict> {
|
|
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<InvalidCreditRequest> {
|
|
service.grantManual(
|
|
TEST_OPERATOR_ID,
|
|
userId,
|
|
0,
|
|
"Invalid adjustment",
|
|
"support-ticket-1044",
|
|
"manual-grant-key-003",
|
|
)
|
|
}
|
|
store.failNextManualGrantInsert = true
|
|
shouldThrow<IllegalStateException> {
|
|
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<InvalidCreditRequest> {
|
|
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<InvalidCreditRequest> {
|
|
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<InsufficientCredits>()
|
|
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<CreditConflict> {
|
|
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")
|