Enhance ledger operations and referral lifecycle
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

Add traceable ledger filtering and permanent referral codes so operators can investigate credit activity without weakening immutable accounting guarantees.
This commit is contained in:
Rocky
2026-08-20 22:14:22 +08:00
parent 74c3fcd45f
commit b5212dcdc2
38 changed files with 3080 additions and 620 deletions
@@ -115,7 +115,6 @@ import com.osglab.account.features.inviteweb.InviteWebConfig
import com.osglab.account.features.inviteweb.InviteOpenRecorder
import com.osglab.account.features.inviteweb.ReferralLookupPort
import com.osglab.account.features.inviteweb.configureInviteWebRoutes
import com.osglab.account.features.referrals.domain.ReferralException
import com.osglab.account.features.referrals.routes.referralRoutes
import com.osglab.account.features.referrals.services.ReferralOperations
import com.osglab.account.features.referrals.services.ReferralService
@@ -312,7 +311,7 @@ fun Application.module() {
rateLimit(ACCOUNT_RATE_LIMIT) {
accountRoutes(koin.get())
creditRoutes(koin.get(), koin.get())
referralRoutes(koin.get(), koin.get())
referralRoutes(koin.get(), appConfig.inviteBaseUrl, koin.get())
storeKitRoutes(koin.get())
}
rateLimit(GATEWAY_RATE_LIMIT) {
@@ -519,13 +518,8 @@ fun accountServerModule(config: AppConfig): Module = module {
)
}
get<AccountService>().seedDisplayName(accountId, displayName)
try {
get<ReferralOperations>().getOrCreateCode(accountId)
} catch (exception: CancellationException) {
throw exception
} catch (_: ReferralException) {
// Referral eligibility must not make account sign-in unavailable.
}
// Referral provisioning is intentionally handled by /v1/referrals/me
// after authentication so referral storage can never block sign-in.
}
}
single {
@@ -585,10 +579,9 @@ fun accountServerModule(config: AppConfig): Module = module {
val transactions = get<BillingTransactionRunner>()
ReferralLookupPort { code ->
transactions.inTransaction { unit ->
val referralCode = unit.referrals.findCode(code)
referralCode?.campaignId
?.let(unit.referrals::findCampaign)
?.isActive(Instant.now()) == true
// Invitation codes are permanent account identifiers. Campaign
// availability is evaluated only when an invitee redeems one.
unit.referrals.findCode(code) != null
}
}
}
@@ -28,12 +28,15 @@ import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.admin.stats.services.AdminProductAnalyticsService
import com.osglab.account.features.admin.stats.services.AdminReferralSort
import com.osglab.account.features.admin.stats.services.AdminStatsService
import com.osglab.account.features.admin.users.models.AdminLedgerDetailsDto
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.models.AdminUserReferralDto
import com.osglab.account.features.admin.users.models.AdminUserSummaryDto
import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery
import com.osglab.account.features.admin.users.repositories.AdminLedgerSort
import com.osglab.account.features.admin.users.repositories.AdminLedgerType
import com.osglab.account.features.admin.users.repositories.AdminUsageType
import com.osglab.account.features.admin.users.repositories.AdminUserListQuery
import com.osglab.account.features.admin.users.repositories.AdminUserStatus
import com.osglab.account.features.admin.users.services.AdminUserNotFoundException
@@ -41,6 +44,7 @@ import com.osglab.account.features.admin.users.services.AdminUsersService
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.CreditNotFound
import com.osglab.account.features.credits.domain.InvalidCreditRequest
import com.osglab.account.features.credits.domain.LedgerEntryType
import io.ktor.http.Cookie
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
@@ -651,21 +655,61 @@ private fun ApplicationCall.adminUserListQuery(): AdminUserListQuery {
}
private fun ApplicationCall.adminLedgerQuery(): AdminLedgerQuery {
requireQueryParameters(setOf("cursor", "limit", "from", "until", "type", "sort", "order"))
requireCreatedAtSort()
requireQueryParameters(
setOf(
"cursor",
"limit",
"from",
"until",
"type",
"entryType",
"usageType",
"referenceId",
"sort",
"order",
),
)
val type = request.queryParameters["type"]?.let {
when (it) {
"reserve" -> AdminLedgerType.RESERVE
"settle" -> AdminLedgerType.SETTLE
"refund" -> AdminLedgerType.REFUND
"grant" -> AdminLedgerType.GRANT
"adjustment" -> AdminLedgerType.ADJUSTMENT
else -> throw IllegalArgumentException("Invalid ledger type")
}
}
val entryType = request.queryParameters["entryType"]?.let {
runCatching { LedgerEntryType.valueOf(it) }.getOrNull()
?: throw IllegalArgumentException("Invalid ledger entry type")
}
val usageType = request.queryParameters["usageType"]?.let {
when (it) {
"polish" -> AdminUsageType.POLISH
"asr" -> AdminUsageType.ASR
"ai" -> AdminUsageType.AI
"agent" -> AdminUsageType.AGENT
"hotword" -> AdminUsageType.HOTWORD
else -> throw IllegalArgumentException("Invalid ledger usage type")
}
}
val referenceId = request.queryParameters["referenceId"]?.let {
runCatching { UUID.fromString(it) }.getOrNull()
?: throw IllegalArgumentException("Invalid ledger reference ID")
}
val sort = request.queryParameters["sort"]?.let {
when (it) {
"createdAt" -> AdminLedgerSort.CREATED_AT
"amount" -> AdminLedgerSort.AMOUNT
else -> throw IllegalArgumentException("Invalid ledger sort")
}
} ?: AdminLedgerSort.CREATED_AT
return AdminLedgerQuery(
time = adminTimeFilter(),
type = type,
entryType = entryType,
usageType = usageType,
referenceId = referenceId,
sort = sort,
order = parseSortOrder(request.queryParameters["order"], AdminSortOrder.DESC),
)
}
@@ -962,21 +1006,34 @@ private fun AdminUserLedgerEntryDto.toLedgerResponse(): AdminLedgerResponse =
AdminLedgerResponse(
entryId = id,
userId = userId,
type = when (type) {
"USAGE_RESERVE" -> "reserve"
"USAGE_SETTLE" -> "settle"
"USAGE_RELEASE", "USAGE_REFUND" -> "refund"
"SIGNUP_TRIAL", "MANUAL_GRANT", "REFERRAL_INVITER", "REFERRAL_INVITEE",
"STOREKIT_PURCHASE", "SUBSCRIPTION_GRANT" -> "grant"
else -> "adjustment"
},
type = entryType.toAdminLedgerType(),
entryType = entryType.name,
amount = amountDelta,
balanceAfter = balanceAfter,
reasonCode = type,
reasonCode = entryType.name,
referenceId = referenceId,
usageType = usageType,
details = details,
createdAt = createdAt,
)
private fun LedgerEntryType.toAdminLedgerType(): String =
when (this) {
LedgerEntryType.USAGE_RESERVE -> "reserve"
LedgerEntryType.USAGE_SETTLE -> "settle"
LedgerEntryType.USAGE_RELEASE,
LedgerEntryType.USAGE_REFUND,
-> "refund"
LedgerEntryType.SIGNUP_TRIAL,
LedgerEntryType.MANUAL_GRANT,
LedgerEntryType.REFERRAL_INVITER,
LedgerEntryType.REFERRAL_INVITEE,
LedgerEntryType.STOREKIT_PURCHASE,
LedgerEntryType.SUBSCRIPTION_GRANT,
-> "grant"
}
private fun AdminOperatorRecord.toResponse(): AdminOperatorResponse =
AdminOperatorResponse(
operatorId = id.toString(),
@@ -1162,10 +1219,13 @@ private data class AdminLedgerResponse(
val entryId: String,
val userId: String,
val type: String,
val entryType: String,
val amount: Long,
val balanceAfter: Long,
val reasonCode: String,
val referenceId: String?,
val usageType: String?,
val details: AdminLedgerDetailsDto?,
val createdAt: String,
)
@@ -1,6 +1,7 @@
package com.osglab.account.features.admin.users.models
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.credits.domain.LedgerEntryType
import kotlinx.serialization.Serializable
@Serializable
@@ -27,12 +28,28 @@ data class AdminUserPageDto(
data class AdminUserLedgerEntryDto(
val id: String,
val userId: String,
val type: String,
val entryType: LedgerEntryType,
val amountDelta: Long,
val balanceAfter: Long,
val referenceId: String?,
val createdAt: String,
val usageType: String? = null,
val details: AdminLedgerDetailsDto? = null,
)
@Serializable
data class AdminLedgerDetailsDto(
val kind: String,
val reason: String? = null,
val operatorName: String? = null,
val productId: String? = null,
val transactionId: String? = null,
val originalTransactionId: String? = null,
val environment: String? = null,
val purchasedAt: String? = null,
val role: String? = null,
val relatedUserId: String? = null,
val reservationId: String? = null,
)
@Serializable
@@ -4,6 +4,7 @@ import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.models.AdminTimeFilter
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.admin.users.models.AdminLedgerDetailsDto
import com.osglab.account.features.admin.users.models.AdminUserDetailDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.models.AdminUserReferralDto
@@ -19,11 +20,15 @@ import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.greater
import org.jetbrains.exposed.v1.core.greaterEq
import org.jetbrains.exposed.v1.core.inList
import org.jetbrains.exposed.v1.core.innerJoin
import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.core.less
import org.jetbrains.exposed.v1.core.like
import org.jetbrains.exposed.v1.core.neq
import org.jetbrains.exposed.v1.core.or
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.andWhere
import org.jetbrains.exposed.v1.jdbc.select
import org.jetbrains.exposed.v1.jdbc.selectAll
import java.time.Instant
import java.util.UUID
@@ -34,9 +39,18 @@ data class AdminUserCursor(
)
data class AdminUserLedgerCursor(
val createdAt: Instant,
val sort: AdminLedgerSort,
val createdAt: Instant? = null,
val amount: Long? = null,
val ledgerEntryId: UUID,
)
) {
init {
require(
(sort == AdminLedgerSort.CREATED_AT && createdAt != null && amount == null) ||
(sort == AdminLedgerSort.AMOUNT && amount != null && createdAt == null),
) { "Ledger cursor value does not match its sort" }
}
}
enum class AdminUserStatus {
ACTIVE,
@@ -65,12 +79,30 @@ enum class AdminLedgerType(
LedgerEntryType.SUBSCRIPTION_GRANT,
),
),
ADJUSTMENT(emptySet()),
}
enum class AdminLedgerSort {
CREATED_AT,
AMOUNT,
}
enum class AdminUsageType(
internal val databaseValue: String,
) {
POLISH("POLISH"),
ASR("ASR"),
AI("AI"),
AGENT("AGENT"),
HOTWORD("HOTWORD"),
}
data class AdminLedgerQuery(
val time: AdminTimeFilter = AdminTimeFilter(),
val type: AdminLedgerType? = null,
val entryType: LedgerEntryType? = null,
val usageType: AdminUsageType? = null,
val referenceId: UUID? = null,
val sort: AdminLedgerSort = AdminLedgerSort.CREATED_AT,
val order: AdminSortOrder = AdminSortOrder.DESC,
)
@@ -217,13 +249,19 @@ class ExposedAdminUsersRepository(
)
}
.sortedBy(AdminUsageAggregateDto::kind)
val recentLedger = support.ledger.filter { it.userId == userId }
val recentLedgerRows = support.ledger.filter { it.userId == userId }
.sortedWith(
compareByDescending<UserLedgerRow>(UserLedgerRow::createdAt)
.thenByDescending { it.id.toString() },
)
.take(ledgerLimit)
.map { it.toDto(support.ledgerUsageTypes[it.referenceId]) }
val recentTrace = loadLedgerTrace(recentLedgerRows)
val recentLedger = recentLedgerRows.map { row ->
row.toDto(
usageType = support.ledgerUsageTypes[row.referenceId],
details = recentTrace.detailsFor(row),
)
}
AdminUserDetailDto(
summary = account.toSummary(support),
referralCode = findReferralCode(userId),
@@ -245,44 +283,7 @@ class ExposedAdminUsersRepository(
cursor: AdminUserLedgerCursor?,
query: AdminLedgerQuery,
): List<AdminUserLedgerEntryDto> = databaseFactory.query {
if (query.type == AdminLedgerType.ADJUSTMENT) return@query emptyList()
val statement = AdminUsersCreditLedgerTable.selectAll()
.where { AdminUsersCreditLedgerTable.userId eq userId.toString() }
query.time.from?.let { from ->
statement.andWhere { AdminUsersCreditLedgerTable.createdAt greaterEq from }
}
query.time.until?.let { until ->
statement.andWhere { AdminUsersCreditLedgerTable.createdAt less until }
}
query.type?.let { type ->
statement.andWhere { AdminUsersCreditLedgerTable.entryType inList type.entryTypes }
}
if (cursor != null) {
statement.andWhere {
if (query.order == AdminSortOrder.ASC) {
(AdminUsersCreditLedgerTable.createdAt greater cursor.createdAt) or
(
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
(AdminUsersCreditLedgerTable.id greater cursor.ledgerEntryId.toString())
)
} else {
(AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or
(
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
)
}
}
}
val sortOrder = query.order.toExposedSortOrder()
val ledger = statement.orderBy(
AdminUsersCreditLedgerTable.createdAt to sortOrder,
AdminUsersCreditLedgerTable.id to sortOrder,
)
.limit(limit)
.map(ResultRow::toUserLedgerRow)
val usageTypes = loadLedgerUsageTypes(ledger)
ledger.map { it.toDto(usageTypes[it.referenceId]) }
loadLedgerEntries(userId, limit, cursor, query)
}
override suspend fun listLatestLedger(
@@ -290,43 +291,120 @@ class ExposedAdminUsersRepository(
cursor: AdminUserLedgerCursor?,
query: AdminLedgerQuery,
): List<AdminUserLedgerEntryDto> = databaseFactory.query {
if (query.type == AdminLedgerType.ADJUSTMENT) return@query emptyList()
val statement = AdminUsersCreditLedgerTable.selectAll()
query.time.from?.let { from ->
statement.andWhere { AdminUsersCreditLedgerTable.createdAt greaterEq from }
}
query.time.until?.let { until ->
statement.andWhere { AdminUsersCreditLedgerTable.createdAt less until }
}
query.type?.let { type ->
statement.andWhere { AdminUsersCreditLedgerTable.entryType inList type.entryTypes }
}
if (cursor != null) {
statement.andWhere {
if (query.order == AdminSortOrder.ASC) {
(AdminUsersCreditLedgerTable.createdAt greater cursor.createdAt) or
(
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
(AdminUsersCreditLedgerTable.id greater cursor.ledgerEntryId.toString())
)
} else {
(AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or
(
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
)
}
loadLedgerEntries(null, limit, cursor, query)
}
}
private fun loadLedgerEntries(
userId: UUID?,
limit: Int,
cursor: AdminUserLedgerCursor?,
query: AdminLedgerQuery,
): List<AdminUserLedgerEntryDto> {
val statement = if (query.usageType == null) {
AdminUsersCreditLedgerTable.selectAll()
} else {
AdminUsersCreditLedgerTable
.innerJoin(
otherTable = AdminUsersProviderRequestsTable,
onColumn = { referenceId },
otherColumn = { reservationId },
)
.select(AdminUsersCreditLedgerTable.columns)
}
userId?.let { id ->
statement.andWhere { AdminUsersCreditLedgerTable.userId eq id.toString() }
}
query.time.from?.let { from ->
statement.andWhere { AdminUsersCreditLedgerTable.createdAt greaterEq from }
}
query.time.until?.let { until ->
statement.andWhere { AdminUsersCreditLedgerTable.createdAt less until }
}
query.type?.let { type ->
statement.andWhere { AdminUsersCreditLedgerTable.entryType inList type.entryTypes }
}
query.entryType?.let { entryType ->
statement.andWhere { AdminUsersCreditLedgerTable.entryType eq entryType }
}
query.referenceId?.let { referenceId ->
statement.andWhere { AdminUsersCreditLedgerTable.referenceId eq referenceId.toString() }
}
query.usageType?.let { usageType ->
statement.andWhere {
if (usageType == AdminUsageType.HOTWORD) {
AdminUsersProviderRequestsTable.requestSource eq usageType.databaseValue
} else {
(
AdminUsersProviderRequestsTable.requestSource.isNull() or
(AdminUsersProviderRequestsTable.requestSource neq AdminUsageType.HOTWORD.databaseValue)
) and
(AdminUsersProviderRequestsTable.capability eq usageType.databaseValue)
}
}
val sortOrder = query.order.toExposedSortOrder()
val ledger = statement.orderBy(
AdminUsersCreditLedgerTable.createdAt to sortOrder,
}
cursor?.let {
require(it.sort == query.sort) { "Ledger cursor sort does not match query" }
statement.andWhere { ledgerAfterCursor(it, query) }
}
val sortOrder = query.order.toExposedSortOrder()
val sortColumn = when (query.sort) {
AdminLedgerSort.CREATED_AT -> AdminUsersCreditLedgerTable.createdAt
AdminLedgerSort.AMOUNT -> AdminUsersCreditLedgerTable.amountDelta
}
val ledger = statement
.orderBy(
sortColumn to sortOrder,
AdminUsersCreditLedgerTable.id to sortOrder,
)
.limit(limit)
.map(ResultRow::toUserLedgerRow)
val usageTypes = loadLedgerUsageTypes(ledger)
ledger.map { it.toDto(usageTypes[it.referenceId]) }
.limit(limit)
.map(ResultRow::toUserLedgerRow)
val usageTypes = loadLedgerUsageTypes(ledger)
val trace = loadLedgerTrace(ledger)
return ledger.map { row ->
row.toDto(
usageType = usageTypes[row.referenceId],
details = trace.detailsFor(row),
)
}
}
private fun ledgerAfterCursor(
cursor: AdminUserLedgerCursor,
query: AdminLedgerQuery,
) = when (query.sort) {
AdminLedgerSort.CREATED_AT -> {
val value = requireNotNull(cursor.createdAt)
if (query.order == AdminSortOrder.ASC) {
(AdminUsersCreditLedgerTable.createdAt greater value) or
(
(AdminUsersCreditLedgerTable.createdAt eq value) and
(AdminUsersCreditLedgerTable.id greater cursor.ledgerEntryId.toString())
)
} else {
(AdminUsersCreditLedgerTable.createdAt less value) or
(
(AdminUsersCreditLedgerTable.createdAt eq value) and
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
)
}
}
AdminLedgerSort.AMOUNT -> {
val value = requireNotNull(cursor.amount)
if (query.order == AdminSortOrder.ASC) {
(AdminUsersCreditLedgerTable.amountDelta greater value) or
(
(AdminUsersCreditLedgerTable.amountDelta eq value) and
(AdminUsersCreditLedgerTable.id greater cursor.ledgerEntryId.toString())
)
} else {
(AdminUsersCreditLedgerTable.amountDelta less value) or
(
(AdminUsersCreditLedgerTable.amountDelta eq value) and
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
)
}
}
}
@@ -401,13 +479,165 @@ private fun loadLedgerUsageTypes(ledger: List<UserLedgerRow>): Map<UUID, String>
}
.associate { row ->
UUID.fromString(requireNotNull(row[AdminUsersProviderRequestsTable.reservationId])) to
(
row[AdminUsersProviderRequestsTable.requestSource]
?: row[AdminUsersProviderRequestsTable.capability]
).lowercase()
if (
row[AdminUsersProviderRequestsTable.requestSource] ==
AdminUsageType.HOTWORD.databaseValue
) {
AdminUsageType.HOTWORD.name.lowercase()
} else {
row[AdminUsersProviderRequestsTable.capability].lowercase()
}
}
}
private data class ManualGrantTrace(
val reason: String,
val operatorName: String,
)
private data class StoreKitTrace(
val productId: String,
val transactionId: String,
val originalTransactionId: String,
val environment: String,
val purchasedAt: Instant,
)
private data class ReferralTrace(
val inviterUserId: UUID,
val inviteeUserId: UUID,
)
private data class LedgerTrace(
val manualGrants: Map<UUID, ManualGrantTrace>,
val storeKitPurchases: Map<UUID, StoreKitTrace>,
val referrals: Map<UUID, ReferralTrace>,
) {
fun detailsFor(row: UserLedgerRow): AdminLedgerDetailsDto? =
when (row.type) {
LedgerEntryType.MANUAL_GRANT -> manualGrants[row.id]?.let {
AdminLedgerDetailsDto(
kind = "manualGrant",
reason = it.reason,
operatorName = it.operatorName,
)
}
LedgerEntryType.STOREKIT_PURCHASE -> storeKitPurchases[row.id]?.let {
AdminLedgerDetailsDto(
kind = "storeKit",
productId = it.productId,
transactionId = it.transactionId,
originalTransactionId = it.originalTransactionId,
environment = it.environment,
purchasedAt = it.purchasedAt.toString(),
)
}
LedgerEntryType.REFERRAL_INVITER,
LedgerEntryType.REFERRAL_INVITEE,
-> row.referenceId?.let(referrals::get)?.let { referral ->
val inviter = row.type == LedgerEntryType.REFERRAL_INVITER
AdminLedgerDetailsDto(
kind = "referral",
role = if (inviter) "inviter" else "invitee",
relatedUserId = (
if (inviter) referral.inviteeUserId else referral.inviterUserId
).toString(),
)
}
LedgerEntryType.USAGE_RESERVE,
LedgerEntryType.USAGE_SETTLE,
LedgerEntryType.USAGE_RELEASE,
LedgerEntryType.USAGE_REFUND,
-> row.referenceId?.let {
AdminLedgerDetailsDto(
kind = "usage",
reservationId = it.toString(),
)
}
LedgerEntryType.SIGNUP_TRIAL,
LedgerEntryType.SUBSCRIPTION_GRANT,
-> null
}
}
private fun loadLedgerTrace(ledger: List<UserLedgerRow>): LedgerTrace {
if (ledger.isEmpty()) return LedgerTrace(emptyMap(), emptyMap(), emptyMap())
val ledgerIds = ledger.map { it.id.toString() }
val manualGrants = AdminUsersAdminCreditGrantsTable
.innerJoin(
otherTable = AdminUsersAdminOperatorsTable,
onColumn = { operatorId },
otherColumn = { id },
)
.select(
AdminUsersAdminCreditGrantsTable.ledgerEntryId,
AdminUsersAdminCreditGrantsTable.reason,
AdminUsersAdminOperatorsTable.username,
)
.where { AdminUsersAdminCreditGrantsTable.ledgerEntryId inList ledgerIds }
.associate { row ->
UUID.fromString(row[AdminUsersAdminCreditGrantsTable.ledgerEntryId]) to
ManualGrantTrace(
reason = row[AdminUsersAdminCreditGrantsTable.reason],
operatorName = row[AdminUsersAdminOperatorsTable.username],
)
}
val storeKitPurchases = AdminUsersStoreKitCreditPurchasesTable
.select(
AdminUsersStoreKitCreditPurchasesTable.ledgerEntryId,
AdminUsersStoreKitCreditPurchasesTable.productId,
AdminUsersStoreKitCreditPurchasesTable.transactionId,
AdminUsersStoreKitCreditPurchasesTable.originalTransactionId,
AdminUsersStoreKitCreditPurchasesTable.environment,
AdminUsersStoreKitCreditPurchasesTable.purchasedAt,
)
.where { AdminUsersStoreKitCreditPurchasesTable.ledgerEntryId inList ledgerIds }
.associate { row ->
UUID.fromString(row[AdminUsersStoreKitCreditPurchasesTable.ledgerEntryId]) to
StoreKitTrace(
productId = row[AdminUsersStoreKitCreditPurchasesTable.productId],
transactionId = row[AdminUsersStoreKitCreditPurchasesTable.transactionId],
originalTransactionId =
row[AdminUsersStoreKitCreditPurchasesTable.originalTransactionId],
environment = row[AdminUsersStoreKitCreditPurchasesTable.environment],
purchasedAt = row[AdminUsersStoreKitCreditPurchasesTable.purchasedAt],
)
}
val referralIds = ledger.asSequence()
.filter {
it.type == LedgerEntryType.REFERRAL_INVITER ||
it.type == LedgerEntryType.REFERRAL_INVITEE
}
.mapNotNull(UserLedgerRow::referenceId)
.map(UUID::toString)
.distinct()
.toList()
val referrals = if (referralIds.isEmpty()) {
emptyMap()
} else {
AdminUsersReferralBindingsTable.select(
AdminUsersReferralBindingsTable.id,
AdminUsersReferralBindingsTable.inviterUserId,
AdminUsersReferralBindingsTable.inviteeUserId,
)
.where { AdminUsersReferralBindingsTable.id inList referralIds }
.associate { row ->
UUID.fromString(row[AdminUsersReferralBindingsTable.id]) to
ReferralTrace(
inviterUserId =
UUID.fromString(row[AdminUsersReferralBindingsTable.inviterUserId]),
inviteeUserId =
UUID.fromString(row[AdminUsersReferralBindingsTable.inviteeUserId]),
)
}
}
return LedgerTrace(manualGrants, storeKitPurchases, referrals)
}
private fun findReferralCode(userId: UUID): String? =
AdminUsersReferralCodesTable.selectAll()
.where { AdminUsersReferralCodesTable.ownerUserId eq userId.toString() }
@@ -473,6 +703,26 @@ private object AdminUsersProviderRequestsTable : Table("provider_requests") {
override val primaryKey = PrimaryKey(accountId, requestId)
}
private object AdminUsersAdminCreditGrantsTable : Table("admin_credit_grants") {
val operatorId = varchar("operator_id", 36)
val reason = varchar("reason", 500)
val ledgerEntryId = varchar("ledger_entry_id", 36)
}
private object AdminUsersAdminOperatorsTable : Table("admin_operators") {
val id = varchar("id", 36)
val username = varchar("username", 64)
}
private object AdminUsersStoreKitCreditPurchasesTable : Table("storekit_credit_purchases") {
val transactionId = varchar("transaction_id", 64)
val originalTransactionId = varchar("original_transaction_id", 64)
val productId = varchar("product_id", 128)
val environment = varchar("environment", 16)
val ledgerEntryId = varchar("ledger_entry_id", 36)
val purchasedAt = timestamp("purchased_at")
}
private object AdminUsersCreditUsageTable : Table("credit_usage_records") {
val userId = varchar("user_id", 36)
val usageKind = enumerationByName<UsageKind>("usage_kind", 8)
@@ -484,6 +734,7 @@ private object AdminUsersCreditUsageTable : Table("credit_usage_records") {
}
private object AdminUsersReferralBindingsTable : Table("referral_bindings") {
val id = varchar("id", 36)
val inviterUserId = varchar("inviter_user_id", 36)
val inviteeUserId = varchar("invitee_user_id", 36)
val rewardStatus = enumerationByName<ReferralRewardStatus>("reward_status", 24)
@@ -508,14 +759,18 @@ private fun ResultRow.toUserLedgerRow() = UserLedgerRow(
createdAt = this[AdminUsersCreditLedgerTable.createdAt],
)
private fun UserLedgerRow.toDto(usageType: String?) = AdminUserLedgerEntryDto(
private fun UserLedgerRow.toDto(
usageType: String?,
details: AdminLedgerDetailsDto?,
) = AdminUserLedgerEntryDto(
id = id.toString(),
userId = userId.toString(),
type = type.name,
entryType = type,
amountDelta = amountDelta,
balanceAfter = balanceAfter,
referenceId = referenceId?.toString(),
usageType = usageType,
details = details,
createdAt = createdAt.toString(),
)
@@ -5,6 +5,7 @@ import com.osglab.account.features.admin.users.models.AdminUserDetailDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerPageDto
import com.osglab.account.features.admin.users.models.AdminUserPageDto
import com.osglab.account.features.admin.users.repositories.AdminLedgerSort
import com.osglab.account.features.admin.users.repositories.AdminUserCursor
import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery
import com.osglab.account.features.admin.users.repositories.AdminUserLedgerCursor
@@ -106,7 +107,9 @@ class AdminUsersService(
load: suspend (Int, AdminUserLedgerCursor?) -> List<AdminUserLedgerEntryDto>,
): AdminUserLedgerPageDto {
require(limit in 1..100) { "Ledger page limit must be between 1 and 100" }
val decodedCursor = cursor?.let { AdminUserLedgerCursorCodec.decode(it, query.order) }
val decodedCursor = cursor?.let {
AdminUserLedgerCursorCodec.decode(it, query.sort, query.order)
}
val results = load(limit + 1, decodedCursor)
val hasMore = results.size > limit
val items = results.take(limit)
@@ -114,9 +117,14 @@ class AdminUsersService(
val last = items.last()
AdminUserLedgerCursorCodec.encode(
AdminUserLedgerCursor(
createdAt = Instant.parse(last.createdAt),
sort = query.sort,
createdAt = last.createdAt.takeIf {
query.sort == AdminLedgerSort.CREATED_AT
}?.let(Instant::parse),
amount = last.amountDelta.takeIf { query.sort == AdminLedgerSort.AMOUNT },
ledgerEntryId = UUID.fromString(last.id),
),
query.sort,
query.order,
)
} else {
@@ -173,13 +181,26 @@ internal object AdminUserCursorCodec {
internal object AdminUserLedgerCursorCodec {
private const val INVALID_CURSOR_MESSAGE = "User ledger cursor is invalid"
fun encode(cursor: AdminUserLedgerCursor, order: AdminSortOrder): String {
val value = "v1|${order.name}|${cursor.createdAt}|${cursor.ledgerEntryId}"
fun encode(
cursor: AdminUserLedgerCursor,
sort: AdminLedgerSort,
order: AdminSortOrder,
): String {
require(cursor.sort == sort) { INVALID_CURSOR_MESSAGE }
val sortValue = when (sort) {
AdminLedgerSort.CREATED_AT -> requireNotNull(cursor.createdAt).toString()
AdminLedgerSort.AMOUNT -> requireNotNull(cursor.amount).toString()
}
val value = "v2|${sort.name}|${order.name}|$sortValue|${cursor.ledgerEntryId}"
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(value.toByteArray(StandardCharsets.UTF_8))
}
fun decode(value: String, expectedOrder: AdminSortOrder): AdminUserLedgerCursor {
fun decode(
value: String,
expectedSort: AdminLedgerSort,
expectedOrder: AdminSortOrder,
): AdminUserLedgerCursor {
require(value.length in 1..256) { INVALID_CURSOR_MESSAGE }
return try {
val decoded = String(
@@ -187,13 +208,36 @@ internal object AdminUserLedgerCursorCodec {
StandardCharsets.UTF_8,
)
val parts = decoded.split('|')
require(parts.size == 4)
require(parts[0] == "v1")
require(parts[1] == expectedOrder.name)
AdminUserLedgerCursor(
createdAt = Instant.parse(parts[2]),
ledgerEntryId = UUID.fromString(parts[3]),
)
when (parts.firstOrNull()) {
"v1" -> {
require(expectedSort == AdminLedgerSort.CREATED_AT)
require(parts.size == 4)
require(parts[1] == expectedOrder.name)
AdminUserLedgerCursor(
sort = AdminLedgerSort.CREATED_AT,
createdAt = Instant.parse(parts[2]),
ledgerEntryId = UUID.fromString(parts[3]),
)
}
"v2" -> {
require(parts.size == 5)
require(parts[1] == expectedSort.name)
require(parts[2] == expectedOrder.name)
AdminUserLedgerCursor(
sort = expectedSort,
createdAt = parts[3].takeIf {
expectedSort == AdminLedgerSort.CREATED_AT
}?.let(Instant::parse),
amount = parts[3].takeIf {
expectedSort == AdminLedgerSort.AMOUNT
}?.toLong(),
ledgerEntryId = UUID.fromString(parts[4]),
)
}
else -> throw IllegalArgumentException(INVALID_CURSOR_MESSAGE)
}
} catch (failure: IllegalArgumentException) {
throw IllegalArgumentException(INVALID_CURSOR_MESSAGE, failure)
}
@@ -26,7 +26,6 @@ import kotlinx.coroutines.withContext
import org.jetbrains.exposed.v1.core.*
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.Database
import org.jetbrains.exposed.v1.jdbc.andWhere
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.insertIgnore
import org.jetbrains.exposed.v1.jdbc.select
@@ -158,6 +157,14 @@ private object ReferralCodes : Table("referral_codes") {
override val primaryKey = PrimaryKey(id)
}
private object ReferralOwnerCodes : Table("referral_owner_codes") {
val ownerUserId = varchar("owner_user_id", 36)
val codeId = varchar("code_id", 36)
val createdAt = timestamp("created_at")
override val primaryKey = PrimaryKey(ownerUserId)
}
private object ReferralBindings : Table("referral_bindings") {
val id = varchar("id", 36)
val inviterUserId = varchar("inviter_user_id", 36)
@@ -465,28 +472,63 @@ private object ExposedCreditsRepository : CreditsRepository {
}
private object ExposedReferralsRepository : ReferralsRepository {
override fun findCodeByOwner(ownerUserId: UUID, campaignId: UUID?): ReferralCode? {
val query = ReferralCodes
override fun findPermanentCodeByOwner(ownerUserId: UUID): ReferralCode? =
ReferralOwnerCodes
.innerJoin(
otherTable = ReferralCodes,
onColumn = { codeId },
otherColumn = { id },
)
.selectAll()
.where { ReferralCodes.ownerUserId eq ownerUserId.toString() }
return if (campaignId == null) {
query.orderBy(ReferralCodes.createdAt, SortOrder.DESC).limit(1).singleOrNull()
} else {
query.andWhere { ReferralCodes.campaignId eq campaignId.toString() }.singleOrNull()
}?.toReferralCode()
}
override fun lockCodeByOwner(ownerUserId: UUID, campaignId: UUID): ReferralCode? =
ReferralCodes
.selectAll()
.where {
(ReferralCodes.ownerUserId eq ownerUserId.toString()) and
(ReferralCodes.campaignId eq campaignId.toString())
}
.forUpdate()
.where { ReferralOwnerCodes.ownerUserId eq ownerUserId.toString() }
.singleOrNull()
?.toReferralCode()
override fun claimPermanentCode(candidate: ReferralCode): ReferralCode? {
findPermanentCodeByOwner(candidate.ownerUserId)?.let { return it }
val inserted = ReferralCodes.insertIgnore {
it[id] = candidate.id.toString()
it[ownerUserId] = candidate.ownerUserId.toString()
it[ownerIdentityFingerprint] = candidate.ownerIdentityFingerprint
it[campaignId] = (candidate.campaignId ?: DEFAULT_REFERRAL_CAMPAIGN_ID).toString()
it[code] = candidate.code
it[createdAt] = candidate.createdAt
}.insertedCount == 1
val storedCode = if (inserted) {
candidate
} else {
ReferralCodes
.selectAll()
.where {
(ReferralCodes.ownerUserId eq candidate.ownerUserId.toString()) and
(
ReferralCodes.campaignId eq
(candidate.campaignId ?: DEFAULT_REFERRAL_CAMPAIGN_ID).toString()
)
}
.forUpdate()
.singleOrNull()
?.toReferralCode()
?: return null
}
ReferralOwnerCodes.insertIgnore {
it[ownerUserId] = candidate.ownerUserId.toString()
it[codeId] = storedCode.id.toString()
it[createdAt] = storedCode.createdAt
}
return ReferralOwnerCodes
.innerJoin(
otherTable = ReferralCodes,
onColumn = { codeId },
otherColumn = { id },
)
.selectAll()
.where { ReferralOwnerCodes.ownerUserId eq candidate.ownerUserId.toString() }
.forUpdate()
.single()
.toReferralCode()
}
override fun findCode(code: String): ReferralCode? =
ReferralCodes
.selectAll()
@@ -494,16 +536,6 @@ private object ExposedReferralsRepository : ReferralsRepository {
.singleOrNull()
?.toReferralCode()
override fun insertCodeIfAbsent(code: ReferralCode): Boolean =
ReferralCodes.insertIgnore {
it[id] = code.id.toString()
it[ownerUserId] = code.ownerUserId.toString()
it[ownerIdentityFingerprint] = code.ownerIdentityFingerprint
it[campaignId] = (code.campaignId ?: DEFAULT_REFERRAL_CAMPAIGN_ID).toString()
it[ReferralCodes.code] = code.code
it[createdAt] = code.createdAt
}.insertedCount == 1
override fun findCampaign(id: UUID): ReferralCampaign? =
ReferralCampaigns
.selectAll()
@@ -299,7 +299,16 @@ class ExposedGatewayRepository(
): ComplimentaryRequestClaim? = databaseFactory.query {
val now = clock.instant()
val expiresAt = now.plus(COMPLIMENTARY_CLAIM_TTL)
val inserted = ComplimentaryRequestsTable.insertIgnore {
val reclaimed = ComplimentaryRequestsTable.update({
complimentaryKey(accountId, purpose, capability) and
(ComplimentaryRequestsTable.status eq COMPLIMENTARY_CLAIMED) and
(ComplimentaryRequestsTable.expiresAt lessEq now)
}) {
it[ComplimentaryRequestsTable.requestId] = requestId
it[ComplimentaryRequestsTable.expiresAt] = expiresAt
it[updatedAt] = now
} == 1
val inserted = !reclaimed && ComplimentaryRequestsTable.insertIgnore {
it[ComplimentaryRequestsTable.accountId] = accountId
it[ComplimentaryRequestsTable.purpose] = purpose.name
it[ComplimentaryRequestsTable.capability] = capability.name
@@ -309,19 +318,6 @@ class ExposedGatewayRepository(
it[createdAt] = now
it[updatedAt] = now
}.insertedCount == 1
val reclaimed = if (!inserted) {
ComplimentaryRequestsTable.update({
complimentaryKey(accountId, purpose, capability) and
(ComplimentaryRequestsTable.status eq COMPLIMENTARY_CLAIMED) and
(ComplimentaryRequestsTable.expiresAt lessEq now)
}) {
it[ComplimentaryRequestsTable.requestId] = requestId
it[ComplimentaryRequestsTable.expiresAt] = expiresAt
it[updatedAt] = now
} == 1
} else {
false
}
if (!inserted && !reclaimed) return@query null
ComplimentaryRequestClaim(accountId, purpose, capability, requestId)
}
@@ -20,8 +20,8 @@ import java.util.Base64
/**
* Read-only boundary used by the public page to verify a referral code.
*
* Implementations must preserve case, apply campaign validity rules, use a bounded database query,
* and never log the code.
* Implementations must preserve case, treat issued codes as permanent, use a bounded database
* query, and never log the code.
*/
fun interface ReferralLookupPort {
suspend fun isValid(code: String): Boolean
@@ -193,7 +193,7 @@ private suspend fun ApplicationCall.respondAasa(aasa: String) {
private suspend fun ApplicationCall.respondInvalidInvitation() {
respondText(
text = "邀请链接无效或已失效 / This invitation link is invalid or expired",
text = "邀请链接无效 / This invitation link is invalid",
contentType = ContentType.Text.Plain.withCharset(Charsets.UTF_8),
status = HttpStatusCode.NotFound,
)
@@ -14,12 +14,14 @@ data class BindReferralRequest(
@Serializable
data class ReferralCodeDto(
val code: String,
val inviteUrl: String,
val campaignId: String?,
val createdAt: String,
) {
companion object {
fun fromDomain(value: ReferralCode) = ReferralCodeDto(
fun fromDomain(value: ReferralCode, inviteBaseUrl: String) = ReferralCodeDto(
code = value.code,
inviteUrl = "${inviteBaseUrl.trimEnd('/')}/${value.code}",
campaignId = value.campaignId?.toString(),
createdAt = value.createdAt.toString(),
)
@@ -66,12 +68,12 @@ data class ReferralCampaignDto(
@Serializable
data class ReferralProfileDto(
val code: ReferralCodeDto?,
val code: ReferralCodeDto,
val binding: ReferralBindingDto?,
) {
companion object {
fun fromDomain(value: ReferralProfile) = ReferralProfileDto(
code = value.code?.let(ReferralCodeDto::fromDomain),
fun fromDomain(value: ReferralProfile, inviteBaseUrl: String) = ReferralProfileDto(
code = ReferralCodeDto.fromDomain(value.code, inviteBaseUrl),
binding = value.binding?.let(ReferralBindingDto::fromDomain),
)
}
@@ -8,14 +8,16 @@ import java.time.Instant
import java.util.UUID
interface ReferralsRepository {
fun findCodeByOwner(ownerUserId: UUID, campaignId: UUID? = null): ReferralCode?
fun findPermanentCodeByOwner(ownerUserId: UUID): ReferralCode?
fun lockCodeByOwner(ownerUserId: UUID, campaignId: UUID): ReferralCode?
/**
* Atomically returns the account's existing permanent code or claims [candidate].
* Returns null only when the candidate code collided and the caller should retry.
*/
fun claimPermanentCode(candidate: ReferralCode): ReferralCode?
fun findCode(code: String): ReferralCode?
fun insertCodeIfAbsent(code: ReferralCode): Boolean
fun findCampaign(id: UUID): ReferralCampaign?
fun listActiveCampaigns(at: Instant): List<ReferralCampaign>
@@ -26,13 +26,14 @@ import java.util.UUID
class ReferralRouteInstaller(
private val service: ReferralOperations,
private val inviteBaseUrl: String,
private val authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor,
) {
fun install(parent: Route) {
parent.route("/v1/referrals") {
get("/me") {
call.referralCall(authenticatedUser) { userId ->
ReferralProfileDto.fromDomain(service.getProfile(userId))
ReferralProfileDto.fromDomain(service.getProfile(userId), inviteBaseUrl)
}
}
get {
@@ -58,7 +59,7 @@ class ReferralRouteInstaller(
}
post("/code") {
call.referralCall(authenticatedUser) { userId ->
ReferralCodeDto.fromDomain(service.getOrCreateCode(userId))
ReferralCodeDto.fromDomain(service.getOrCreateCode(userId), inviteBaseUrl)
}
}
post("/bind") {
@@ -73,9 +74,10 @@ class ReferralRouteInstaller(
fun Route.referralRoutes(
service: ReferralOperations,
inviteBaseUrl: String,
authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor,
) {
ReferralRouteInstaller(service, authenticatedUser).install(this)
ReferralRouteInstaller(service, inviteBaseUrl, authenticatedUser).install(this)
}
private suspend fun ApplicationCall.referralCall(
@@ -1,6 +1,7 @@
package com.osglab.account.features.referrals.services
import com.osglab.account.features.credits.repositories.BillingTransactionRunner
import com.osglab.account.features.referrals.domain.DEFAULT_REFERRAL_CAMPAIGN_ID
import com.osglab.account.features.referrals.domain.InviteCodeGenerator
import com.osglab.account.features.referrals.domain.InvalidReferralRequest
import com.osglab.account.features.referrals.domain.ReferralBinding
@@ -34,13 +35,13 @@ typealias ReferralRiskIdentity = ReferralRiskAssessment
typealias ReferralRiskProvider = ReferralRiskPort
data class ReferralProfile(
val code: ReferralCode?,
val code: ReferralCode,
val binding: ReferralBinding?,
)
/**
* Public referral boundary. Binding and code creation remain transactionally
* consistent even when callers retry after a timeout.
* Public referral boundary. Each account receives one permanent code, while the
* active campaign is selected only when an invitee redeems that code.
*/
interface ReferralOperations {
suspend fun getOrCreateCode(ownerUserId: UUID): ReferralCode
@@ -76,19 +77,22 @@ class ReferralService(
}
override suspend fun getOrCreateCode(ownerUserId: UUID, campaignId: UUID?): ReferralCode {
transactions.inTransaction { unit ->
unit.referrals.findPermanentCodeByOwner(ownerUserId)
}?.let { return it }
val ownerIdentity = requireEligibleIdentity(ownerUserId)
return transactions.inTransaction { unit ->
val now = clock.instant()
val campaign = if (campaignId == null) {
unit.referrals.listActiveCampaigns(now).firstOrNull()
?: throw ReferralNotFound("No active referral campaign exists")
unit.referrals.findPermanentCodeByOwner(ownerUserId)?.let {
return@inTransaction it
}
val storageCampaignId = if (campaignId == null) {
DEFAULT_REFERRAL_CAMPAIGN_ID
} else {
unit.referrals.findCampaign(campaignId)
?: throw ReferralNotFound("Referral campaign does not exist")
}
if (!campaign.isActive(now)) throw ReferralNotFound("Referral campaign is not active")
unit.referrals.findCodeByOwner(ownerUserId, campaign.id)?.let {
return@inTransaction it
campaignId
}
repeat(MAX_CODE_ATTEMPTS) {
val candidate = ReferralCode(
@@ -97,15 +101,14 @@ class ReferralService(
ownerIdentityFingerprint = ownerIdentity.identityFingerprint,
code = codeGenerator.generate(),
createdAt = now,
campaignId = campaign.id,
// The campaign column is retained for historical compatibility only.
// A referral code now belongs to the account for its entire lifetime.
campaignId = storageCampaignId,
)
if (candidate.code.length < 20) {
throw IllegalStateException("Invite code generator must provide at least 120 bits")
}
if (unit.referrals.insertCodeIfAbsent(candidate)) {
return@inTransaction candidate
}
unit.referrals.lockCodeByOwner(ownerUserId, campaign.id)?.let {
unit.referrals.claimPermanentCode(candidate)?.let {
return@inTransaction it
}
}
@@ -123,7 +126,11 @@ class ReferralService(
else throw ReferralConflict("This account is already bound to another inviter")
}
if (existing != null) return existing
val referralCode = transactions.inTransaction { unit ->
unit.referrals.findCode(code)
} ?: throw ReferralNotFound("Referral code does not exist")
val inviteeIdentity = requireEligibleIdentity(inviteeUserId)
requireEligibleIdentity(referralCode.ownerUserId)
val registeredAt = registrationTimeProvider.registeredAt(inviteeUserId)
?: throw ReferralNotFound("Registration time is unavailable")
val now = clock.instant()
@@ -136,15 +143,11 @@ class ReferralService(
}
val referralCode = unit.referrals.findCode(code)
?: throw ReferralNotFound("Referral code does not exist")
val campaign = referralCode.campaignId
?.let(unit.referrals::findCampaign)
if (campaign != null && !campaign.isActive(now)) {
throw ReferralNotFound("Referral campaign is not active")
}
val effectiveWindow = campaign
?.bindingWindowSeconds
?.let(Duration::ofSeconds)
?: bindingWindow
// Campaigns define the reward policy at redemption time; they no longer
// define the lifetime of the account's permanent invitation code.
val campaign = unit.referrals.listActiveCampaigns(now).firstOrNull()
?: throw ReferralNotFound("No active referral campaign exists")
val effectiveWindow = Duration.ofSeconds(campaign.bindingWindowSeconds)
if (!ReferralBindingRules.isWithinWindow(registeredAt, now, effectiveWindow)) {
throw ReferralWindowExpired()
}
@@ -164,7 +167,7 @@ class ReferralService(
boundAt = now,
rewardedAt = null,
rewardSettlementId = null,
campaignId = referralCode.campaignId,
campaignId = campaign.id,
)
if (unit.referrals.insertBindingIfAbsent(binding)) {
binding
@@ -0,0 +1,17 @@
ALTER TABLE credit_ledger
MODIFY reference_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL;
ALTER TABLE provider_requests
MODIFY reservation_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL;
CREATE INDEX idx_credit_ledger_amount_id
ON credit_ledger (amount_delta, id);
CREATE INDEX idx_credit_ledger_user_amount_id
ON credit_ledger (user_id, amount_delta, id);
CREATE INDEX idx_provider_requests_capability_source_reservation
ON provider_requests (capability, request_source, reservation_id);
CREATE INDEX idx_provider_requests_source_capability_reservation
ON provider_requests (request_source, capability, reservation_id);
@@ -0,0 +1,29 @@
-- Keep historical campaign-scoped codes as valid aliases while selecting exactly
-- one permanent code for every account. New writes claim this mapping atomically.
CREATE TABLE referral_owner_codes (
owner_user_id VARCHAR(36) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
code_id CHAR(36) NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
PRIMARY KEY (owner_user_id),
UNIQUE KEY uk_referral_owner_codes_code (code_id),
CONSTRAINT fk_referral_owner_codes_code
FOREIGN KEY (code_id) REFERENCES referral_codes (id) ON DELETE CASCADE
) ENGINE = InnoDB;
-- Preserve the code most recently distributed by the previous implementation.
-- Older codes remain in referral_codes so already-shared links never break.
INSERT INTO referral_owner_codes (owner_user_id, code_id, created_at)
SELECT candidate.owner_user_id, candidate.id, candidate.created_at
FROM referral_codes candidate
WHERE NOT EXISTS (
SELECT 1
FROM referral_codes newer
WHERE newer.owner_user_id = candidate.owner_user_id
AND (
newer.created_at > candidate.created_at
OR (
newer.created_at = candidate.created_at
AND newer.id > candidate.id
)
)
);
@@ -39,6 +39,36 @@ class DeploymentConsistencyTest : FunSpec({
openApi shouldContain "referralCode"
}
test("admin ledger operations stay indexed exact and privacy minimized") {
val migration = root.read(
"src/main/resources/db/migration/V19__admin_ledger_operations.sql",
)
migration shouldContain
"MODIFY reference_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL"
migration shouldContain
"MODIFY reservation_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL"
migration shouldContain "ON credit_ledger (amount_delta, id)"
migration shouldContain "ON credit_ledger (user_id, amount_delta, id)"
migration shouldContain "ON provider_requests (capability, request_source, reservation_id)"
migration shouldContain "ON provider_requests (request_source, capability, reservation_id)"
val openApi = root.read("docs/openapi.yaml")
val ledgerSchema = openApi
.substringAfter(" LedgerEntryType:")
.substringBefore(" AdminLedgerPage:")
LEDGER_ENTRY_TYPE_NAMES.forEach(ledgerSchema::shouldContain)
ledgerSchema shouldContain "deprecated: true"
ledgerSchema shouldContain "referenceId"
ledgerSchema shouldContain "reservationId"
ledgerSchema shouldContain
"required: [entryId, userId, type, entryType, amount, balanceAfter, reasonCode, createdAt]"
ledgerSchema shouldNotContain "enum: [grant, reserve, settle, refund, adjustment]"
ledgerSchema shouldNotContain "idempotencyKey"
ledgerSchema shouldNotContain "appAccountToken"
ledgerSchema shouldNotContain "signedTransaction"
ledgerSchema shouldNotContain "appleSubject"
}
test("provider defaults and Apple integrity contract stay production compatible") {
val providerConfigurations = listOf(
root.read("src/main/kotlin/com/osglab/account/config/AppConfig.kt"),
@@ -248,6 +278,19 @@ class DeploymentConsistencyTest : FunSpec({
private fun Path.read(relativePath: String): String =
Files.readString(resolve(relativePath))
private val LEDGER_ENTRY_TYPE_NAMES = listOf(
"SIGNUP_TRIAL",
"MANUAL_GRANT",
"USAGE_RESERVE",
"USAGE_SETTLE",
"USAGE_RELEASE",
"USAGE_REFUND",
"REFERRAL_INVITER",
"REFERRAL_INVITEE",
"STOREKIT_PURCHASE",
"SUBSCRIPTION_GRANT",
)
private val EXPECTED_PUBLIC_PATHS = setOf(
"/health",
"/health/live",
@@ -13,12 +13,19 @@ import com.osglab.account.features.admin.services.AdminOperatorException
import com.osglab.account.features.admin.services.AdminSessionService
import com.osglab.account.features.admin.stats.services.AdminProductAnalyticsService
import com.osglab.account.features.admin.stats.services.AdminStatsService
import com.osglab.account.features.admin.users.models.AdminLedgerDetailsDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.models.AdminUserLedgerPageDto
import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery
import com.osglab.account.features.admin.users.repositories.AdminLedgerSort
import com.osglab.account.features.admin.users.repositories.AdminLedgerType
import com.osglab.account.features.admin.users.repositories.AdminUsageType
import com.osglab.account.features.admin.users.services.AdminUsersService
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.CreditNotFound
import com.osglab.account.features.credits.domain.InvalidCreditRequest
import com.osglab.account.features.credits.domain.LedgerEntryType
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.ktor.client.statement.bodyAsText
import io.ktor.client.request.get
@@ -40,7 +47,9 @@ import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.slot
import kotlinx.serialization.json.Json
import java.time.Instant
import java.util.UUID
import kotlin.time.Duration.Companion.minutes
import kotlin.test.Test
@@ -231,12 +240,16 @@ class AdminRoutesTest {
AdminUserLedgerEntryDto(
id = "ffffffff-ffff-ffff-ffff-ffffffffffff",
userId = "11111111-1111-4111-8111-111111111111",
type = "USAGE_SETTLE",
entryType = LedgerEntryType.USAGE_SETTLE,
amountDelta = -18,
balanceAfter = 102,
referenceId = null,
referenceId = "22222222-2222-4222-8222-222222222222",
createdAt = "2026-08-19T09:00:00Z",
usageType = "hotword",
details = AdminLedgerDetailsDto(
kind = "usage",
reservationId = "22222222-2222-4222-8222-222222222222",
),
),
),
nextCursor = null,
@@ -255,8 +268,136 @@ class AdminRoutesTest {
assertEquals(HttpStatusCode.OK, response.status)
response.bodyAsText() shouldContain """"userId":"11111111-1111-4111-8111-111111111111""""
response.bodyAsText() shouldContain """"entryType":"USAGE_SETTLE""""
response.bodyAsText() shouldContain """"reasonCode":"USAGE_SETTLE""""
response.bodyAsText() shouldContain """"referenceId":"22222222-2222-4222-8222-222222222222""""
response.bodyAsText() shouldContain """"usageType":"hotword""""
response.bodyAsText() shouldContain """"kind":"usage""""
}
@Test
fun `ledger response maps all exact entry types and privacy safe trace details`() = testApplication {
val usersService = mockk<AdminUsersService>()
val entries = LedgerEntryType.entries.mapIndexed { index, entryType ->
AdminUserLedgerEntryDto(
id = UUID.nameUUIDFromBytes("ledger-$index".toByteArray()).toString(),
userId = "11111111-1111-4111-8111-111111111111",
entryType = entryType,
amountDelta = if (entryType.name.startsWith("USAGE")) -10 else 10,
balanceAfter = 100,
referenceId = null,
createdAt = "2026-08-19T09:00:00Z",
details = when (entryType) {
LedgerEntryType.MANUAL_GRANT -> AdminLedgerDetailsDto(
kind = "manualGrant",
reason = "customer recovery",
operatorName = "support",
)
LedgerEntryType.STOREKIT_PURCHASE -> AdminLedgerDetailsDto(
kind = "storeKit",
productId = "credits.100",
transactionId = "2000000000001",
originalTransactionId = "2000000000001",
environment = "SANDBOX",
purchasedAt = "2026-08-19T08:59:00Z",
)
LedgerEntryType.REFERRAL_INVITER -> AdminLedgerDetailsDto(
kind = "referral",
role = "inviter",
relatedUserId = "22222222-2222-4222-8222-222222222222",
)
else -> null
},
)
} + AdminUserLedgerEntryDto(
id = UUID.randomUUID().toString(),
userId = "11111111-1111-4111-8111-111111111111",
entryType = LedgerEntryType.MANUAL_GRANT,
amountDelta = 10,
balanceAfter = 110,
referenceId = null,
createdAt = "2026-08-19T09:01:00Z",
details = null,
)
coEvery { usersService.latestLedger(any(), any(), any()) } returns
AdminUserLedgerPageDto(entries, null)
application {
installAdminTestRoutes(
sessionService = sessionFixture(AdminRole.SUPPORT),
usersService = usersService,
)
}
val response = client.get("/v1/admin/credits/ledger") {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
}
assertEquals(HttpStatusCode.OK, response.status)
val body = response.bodyAsText()
LedgerEntryType.entries.forEach { entryType ->
val coarse = when (entryType) {
LedgerEntryType.USAGE_RESERVE -> "reserve"
LedgerEntryType.USAGE_SETTLE -> "settle"
LedgerEntryType.USAGE_RELEASE,
LedgerEntryType.USAGE_REFUND,
-> "refund"
else -> "grant"
}
body shouldContain """"type":"$coarse","entryType":"${entryType.name}""""
body shouldContain """"reasonCode":"${entryType.name}""""
}
body shouldContain """"kind":"manualGrant""""
body shouldContain """"operatorName":"support""""
body shouldContain """"kind":"storeKit""""
body shouldContain """"kind":"referral""""
}
@Test
fun `ledger route forwards strict combined filters and amount sort`() = testApplication {
val usersService = mockk<AdminUsersService>()
val captured = slot<AdminLedgerQuery>()
coEvery { usersService.latestLedger(100, null, capture(captured)) } returns
AdminUserLedgerPageDto(emptyList(), null)
application {
installAdminTestRoutes(
sessionService = sessionFixture(AdminRole.SUPPORT),
usersService = usersService,
)
}
val referenceId = "22222222-2222-4222-8222-222222222222"
val response = client.get(
"/v1/admin/credits/ledger" +
"?from=2026-08-19T00:00:00Z" +
"&until=2026-08-20T00:00:00Z" +
"&type=settle" +
"&entryType=USAGE_SETTLE" +
"&usageType=hotword" +
"&referenceId=$referenceId" +
"&sort=amount&order=asc",
) {
header("X-OSG-mTLS-Verified", "SUCCESS")
header(HttpHeaders.Cookie, "osg_admin_session=session-token")
}
assertEquals(HttpStatusCode.OK, response.status)
captured.captured shouldBe AdminLedgerQuery(
time = com.osglab.account.features.admin.models.AdminTimeFilter(
from = Instant.parse("2026-08-19T00:00:00Z"),
until = Instant.parse("2026-08-20T00:00:00Z"),
),
type = AdminLedgerType.SETTLE,
entryType = LedgerEntryType.USAGE_SETTLE,
usageType = AdminUsageType.HOTWORD,
referenceId = UUID.fromString(referenceId),
sort = AdminLedgerSort.AMOUNT,
order = com.osglab.account.features.admin.models.AdminSortOrder.ASC,
)
}
@Test
@@ -269,6 +410,11 @@ class AdminRoutesTest {
val invalidPaths = listOf(
"/v1/admin/users?from=2026-08-20T00:00:00Z&until=2026-08-20T00:00:00Z",
"/v1/admin/credits/ledger?type=unknown",
"/v1/admin/credits/ledger?type=adjustment",
"/v1/admin/credits/ledger?entryType=usage_settle",
"/v1/admin/credits/ledger?usageType=voice",
"/v1/admin/credits/ledger?referenceId=not-a-uuid",
"/v1/admin/credits/ledger?sort=balance",
"/v1/admin/operators?enabled=1",
"/v1/admin/audit?action=NOT_AN_ACTION",
"/v1/admin/referrals?range=30d&limit=101",
@@ -0,0 +1,588 @@
package com.osglab.account.features.admin.users
import com.osglab.account.config.DatabaseConfig
import com.osglab.account.config.DatabaseFactory
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.models.AdminTimeFilter
import com.osglab.account.features.admin.users.models.AdminUserLedgerEntryDto
import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery
import com.osglab.account.features.admin.users.repositories.AdminLedgerSort
import com.osglab.account.features.admin.users.repositories.AdminLedgerType
import com.osglab.account.features.admin.users.repositories.AdminUsageType
import com.osglab.account.features.admin.users.repositories.ExposedAdminUsersRepository
import com.osglab.account.features.admin.users.services.AdminUsersService
import com.osglab.account.features.credits.domain.LedgerEntryType
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldContainExactly
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.kotest.matchers.string.shouldNotContain
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.opentest4j.TestAbortedException
import org.testcontainers.DockerClientFactory
import org.testcontainers.containers.MySQLContainer
import java.sql.Connection
import java.sql.DriverManager
import java.time.Instant
import java.time.LocalDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.UUID
class AdminUsersRepositoryIntegrationTest : FunSpec({
val fixture = lazy(::startIntegrationDatabase)
afterSpec {
if (fixture.isInitialized()) fixture.value.close()
}
test("amount keyset pagination is stable and V19 indexes have exact column order") {
val database = fixture.value
val userId = database.insertAccount()
val tiedIds = listOf(UUID.randomUUID(), UUID.randomUUID())
.sortedBy(UUID::toString)
val lowAmountId = UUID.randomUUID()
database.insertLedger(
LedgerSeed(lowAmountId, userId, LedgerEntryType.SIGNUP_TRIAL, -5, 95),
LedgerSeed(tiedIds[1], userId, LedgerEntryType.MANUAL_GRANT, 10, 105),
LedgerSeed(tiedIds[0], userId, LedgerEntryType.STOREKIT_PURCHASE, 10, 115),
)
val ascending = database.service.collectLedger(
userId = userId,
limit = 2,
query = AdminLedgerQuery(
sort = AdminLedgerSort.AMOUNT,
order = AdminSortOrder.ASC,
),
)
ascending.map(AdminUserLedgerEntryDto::id) shouldContainExactly
listOf(lowAmountId, tiedIds[0], tiedIds[1]).map(UUID::toString)
ascending.map(AdminUserLedgerEntryDto::id).distinct().size shouldBe 3
val descending = database.service.collectLedger(
userId = userId,
limit = 2,
query = AdminLedgerQuery(
sort = AdminLedgerSort.AMOUNT,
order = AdminSortOrder.DESC,
),
)
descending.map(AdminUserLedgerEntryDto::id) shouldContainExactly
listOf(tiedIds[1], tiedIds[0], lowAmountId).map(UUID::toString)
descending.map(AdminUserLedgerEntryDto::id).distinct().size shouldBe 3
database.indexColumns("credit_ledger", "idx_credit_ledger_amount_id") shouldContainExactly
listOf("amount_delta", "id")
database.indexColumns(
"credit_ledger",
"idx_credit_ledger_user_amount_id",
) shouldContainExactly listOf("user_id", "amount_delta", "id")
database.indexColumns(
"provider_requests",
"idx_provider_requests_capability_source_reservation",
) shouldContainExactly listOf("capability", "request_source", "reservation_id")
database.indexColumns(
"provider_requests",
"idx_provider_requests_source_capability_reservation",
) shouldContainExactly listOf("request_source", "capability", "reservation_id")
database.columnCollation("credit_ledger", "reference_id") shouldBe "ascii_bin"
database.columnCollation("provider_requests", "reservation_id") shouldBe "ascii_bin"
}
test("usage filtering happens before limit and honors HOTWORD source priority") {
val database = fixture.value
val userId = database.insertAccount()
val hotwordPolish = UUID.randomUUID()
val regularPolishNewest = UUID.randomUUID()
val regularAi = UUID.randomUUID()
val hotwordAi = UUID.randomUUID()
val regularPolishOldest = UUID.randomUUID()
listOf(
ProviderSeed(hotwordPolish, "POLISH", "HOTWORD"),
ProviderSeed(regularPolishNewest, "POLISH", null),
ProviderSeed(regularAi, "AI", null),
ProviderSeed(hotwordAi, "AI", "HOTWORD"),
ProviderSeed(regularPolishOldest, "POLISH", null),
).forEach { database.insertProviderRequest(userId, it) }
database.insertLedger(
LedgerSeed(
UUID.randomUUID(),
userId,
LedgerEntryType.USAGE_SETTLE,
-1,
99,
hotwordPolish,
Instant.parse("2026-08-20T00:00:05Z"),
),
LedgerSeed(
UUID.randomUUID(),
userId,
LedgerEntryType.USAGE_SETTLE,
-1,
98,
regularPolishNewest,
Instant.parse("2026-08-20T00:00:04Z"),
),
LedgerSeed(
UUID.randomUUID(),
userId,
LedgerEntryType.USAGE_SETTLE,
-1,
97,
regularAi,
Instant.parse("2026-08-20T00:00:03Z"),
),
LedgerSeed(
UUID.randomUUID(),
userId,
LedgerEntryType.USAGE_SETTLE,
-1,
96,
hotwordAi,
Instant.parse("2026-08-20T00:00:02Z"),
),
LedgerSeed(
UUID.randomUUID(),
userId,
LedgerEntryType.USAGE_SETTLE,
-1,
95,
regularPolishOldest,
Instant.parse("2026-08-20T00:00:01Z"),
),
)
val hotword = database.service.collectLedger(
userId = userId,
limit = 1,
query = AdminLedgerQuery(usageType = AdminUsageType.HOTWORD),
)
hotword.map(AdminUserLedgerEntryDto::referenceId) shouldContainExactly
listOf(hotwordPolish, hotwordAi).map(UUID::toString)
hotword.map(AdminUserLedgerEntryDto::usageType) shouldContainExactly
listOf("hotword", "hotword")
val polish = database.service.collectLedger(
userId = userId,
limit = 1,
query = AdminLedgerQuery(usageType = AdminUsageType.POLISH),
)
polish.map(AdminUserLedgerEntryDto::referenceId) shouldContainExactly
listOf(regularPolishNewest, regularPolishOldest).map(UUID::toString)
polish.map(AdminUserLedgerEntryDto::usageType) shouldContainExactly
listOf("polish", "polish")
}
test("combined filters and privacy safe trace details use real associations") {
val database = fixture.value
val userId = database.insertAccount()
val relatedAccountId = database.insertAccount()
val manualLedgerId = UUID.randomUUID()
val storeKitLedgerId = UUID.randomUUID()
val referralLedgerId = UUID.randomUUID()
val usageLedgerId = UUID.randomUUID()
val missingLedgerId = UUID.randomUUID()
val combinationReference = UUID.randomUUID()
val referralBindingId = UUID.randomUUID()
val usageReservationId = UUID.randomUUID()
database.insertLedger(
LedgerSeed(
manualLedgerId,
userId,
LedgerEntryType.MANUAL_GRANT,
25,
125,
combinationReference,
Instant.parse("2026-08-20T00:00:10Z"),
),
LedgerSeed(
UUID.randomUUID(),
userId,
LedgerEntryType.MANUAL_GRANT,
25,
150,
combinationReference,
Instant.parse("2026-08-20T00:01:00Z"),
),
LedgerSeed(
UUID.randomUUID(),
userId,
LedgerEntryType.USAGE_SETTLE,
-1,
149,
combinationReference,
Instant.parse("2026-08-20T00:00:20Z"),
),
LedgerSeed(
storeKitLedgerId,
userId,
LedgerEntryType.STOREKIT_PURCHASE,
50,
199,
),
LedgerSeed(
referralLedgerId,
userId,
LedgerEntryType.REFERRAL_INVITER,
10,
209,
referralBindingId,
),
LedgerSeed(
usageLedgerId,
userId,
LedgerEntryType.USAGE_RESERVE,
-3,
206,
usageReservationId,
),
LedgerSeed(
missingLedgerId,
userId,
LedgerEntryType.MANUAL_GRANT,
1,
207,
),
)
val operatorName = database.insertManualGrant(userId, manualLedgerId)
val storeKit = database.insertStoreKitPurchase(userId, storeKitLedgerId)
database.insertReferralBinding(userId, relatedAccountId, referralBindingId)
database.insertProviderRequest(
userId,
ProviderSeed(usageReservationId, "AI", null),
)
val combined = database.service.collectLedger(
userId = userId,
limit = 1,
query = AdminLedgerQuery(
time = AdminTimeFilter(
from = Instant.parse("2026-08-20T00:00:00Z"),
until = Instant.parse("2026-08-20T00:01:00Z"),
),
type = AdminLedgerType.GRANT,
entryType = LedgerEntryType.MANUAL_GRANT,
referenceId = combinationReference,
order = AdminSortOrder.ASC,
),
)
combined.map(AdminUserLedgerEntryDto::id) shouldContainExactly
listOf(manualLedgerId.toString())
val entries = database.service.collectLedger(userId, limit = 100)
.associateBy { UUID.fromString(it.id) }
entries.getValue(manualLedgerId).details.shouldNotBeNull().apply {
kind shouldBe "manualGrant"
reason shouldBe "customer recovery"
this.operatorName shouldBe operatorName
}
entries.getValue(storeKitLedgerId).details.shouldNotBeNull().apply {
kind shouldBe "storeKit"
productId shouldBe "credits.50"
transactionId shouldBe storeKit.transactionId
originalTransactionId shouldBe storeKit.originalTransactionId
environment shouldBe "SANDBOX"
purchasedAt shouldBe "2026-08-20T00:00:30Z"
}
entries.getValue(referralLedgerId).details.shouldNotBeNull().apply {
kind shouldBe "referral"
role shouldBe "inviter"
relatedUserId shouldBe relatedAccountId.toString()
}
entries.getValue(usageLedgerId).details.shouldNotBeNull().apply {
kind shouldBe "usage"
reservationId shouldBe usageReservationId.toString()
}
entries.getValue(missingLedgerId).details.shouldBeNull()
val json = Json {
explicitNulls = false
encodeDefaults = true
}
val serialized = entries.values.joinToString("\n") { json.encodeToString(it) }
serialized shouldContain "\"operatorName\":\"$operatorName\""
serialized shouldContain "\"reservationId\":\"$usageReservationId\""
listOf(
"idempotencyKey",
"appAccountToken",
"signedTransaction",
"appleSubject",
"prompt",
"transcript",
"modelOutput",
).forEach(serialized::shouldNotContain)
json.encodeToString(entries.getValue(missingLedgerId)) shouldNotContain "\"details\""
}
})
private data class LedgerSeed(
val id: UUID,
val userId: UUID,
val entryType: LedgerEntryType,
val amount: Long,
val balanceAfter: Long,
val referenceId: UUID? = null,
val createdAt: Instant = Instant.parse("2026-08-20T00:00:00Z"),
)
private data class ProviderSeed(
val reservationId: UUID,
val capability: String,
val requestSource: String?,
)
private data class StoreKitSeedResult(
val transactionId: String,
val originalTransactionId: String,
)
private class AdminLedgerIntegrationDatabase(
private val jdbcUrl: String,
private val username: String,
private val password: String,
private val container: AdminLedgerMySqlContainer?,
private val factory: DatabaseFactory,
) : AutoCloseable {
val service = AdminUsersService(ExposedAdminUsersRepository(factory))
fun insertAccount(): UUID {
val id = UUID.randomUUID()
execute(
"""
INSERT INTO accounts (id, apple_sub, created_at, updated_at)
VALUES ('$id', 'integration-$id', CURRENT_TIMESTAMP(6), CURRENT_TIMESTAMP(6))
""",
)
return id
}
fun insertLedger(vararg entries: LedgerSeed) {
entries.forEach { entry ->
val reference = entry.referenceId?.let { "'$it'" } ?: "NULL"
execute(
"""
INSERT INTO credit_ledger (
id, user_id, entry_type, amount_delta, balance_after,
idempotency_key, reference_id, created_at
) VALUES (
'${entry.id}', '${entry.userId}', '${entry.entryType.name}',
${entry.amount}, ${entry.balanceAfter}, 'integration:${entry.id}',
$reference, '${entry.createdAt.toDatabaseTimestamp()}'
)
""",
)
}
}
fun insertProviderRequest(userId: UUID, seed: ProviderSeed) {
val source = seed.requestSource?.let { "'$it'" } ?: "NULL"
execute(
"""
INSERT INTO provider_requests (
request_id, account_id, reservation_id, provider_id,
capability, request_source, status, created_at
) VALUES (
'request-${seed.reservationId}', '$userId', '${seed.reservationId}',
'integration-provider', '${seed.capability}', $source, 'SETTLED',
CURRENT_TIMESTAMP(6)
)
""",
)
}
fun insertManualGrant(userId: UUID, ledgerEntryId: UUID): String {
val operatorId = UUID.randomUUID()
val auditId = UUID.randomUUID()
val operatorName = "support-${operatorId.toString().take(8)}"
execute(
"""
INSERT INTO admin_operators (
id, username, password_hash, encrypted_totp_secret, role
) VALUES (
'$operatorId', '$operatorName', 'integration-password-hash',
'integration-totp-secret', 'SUPPORT'
)
""",
"""
INSERT INTO admin_audit_log (
id, actor_operator_id, action, outcome, target_type,
target_id, occurred_at
) VALUES (
'$auditId', '$operatorId', 'MANUAL_CREDIT_GRANTED', 'SUCCESS',
'ACCOUNT', '$userId', CURRENT_TIMESTAMP(6)
)
""",
"""
INSERT INTO admin_credit_grants (
id, operator_id, account_id, amount, reason, idempotency_key,
ledger_entry_id, audit_log_id, created_at
) VALUES (
'${UUID.randomUUID()}', '$operatorId', '$userId', 25,
'customer recovery', 'manual:$ledgerEntryId', '$ledgerEntryId',
'$auditId', CURRENT_TIMESTAMP(6)
)
""",
)
return operatorName
}
fun insertStoreKitPurchase(userId: UUID, ledgerEntryId: UUID): StoreKitSeedResult {
val transactionId = UUID.randomUUID().toString()
val originalTransactionId = UUID.randomUUID().toString()
execute(
"""
INSERT INTO storekit_credit_purchases (
id, transaction_id, original_transaction_id, user_id,
app_account_token, product_id, environment, credits_granted,
ledger_entry_id, signed_transaction_sha256, purchased_at,
signed_at, created_at
) VALUES (
'${UUID.randomUUID()}', '$transactionId', '$originalTransactionId', '$userId',
'${UUID.randomUUID()}', 'credits.50', 'SANDBOX', 50,
'$ledgerEntryId', '${"a".repeat(64)}',
'${Instant.parse("2026-08-20T00:00:30Z").toDatabaseTimestamp()}',
'${Instant.parse("2026-08-20T00:00:31Z").toDatabaseTimestamp()}',
CURRENT_TIMESTAMP(6)
)
""",
)
return StoreKitSeedResult(transactionId, originalTransactionId)
}
fun insertReferralBinding(
inviterUserId: UUID,
inviteeUserId: UUID,
bindingId: UUID,
) {
val codeId = UUID.randomUUID()
execute(
"""
INSERT INTO referral_codes (id, owner_user_id, code, created_at)
VALUES ('$codeId', '$inviterUserId', 'CODE${codeId.toString().take(8)}', CURRENT_TIMESTAMP(6))
""",
"""
INSERT INTO referral_bindings (
id, inviter_user_id, invitee_user_id, code_id, bound_at
) VALUES (
'$bindingId', '$inviterUserId', '$inviteeUserId', '$codeId',
CURRENT_TIMESTAMP(6)
)
""",
)
}
fun indexColumns(table: String, index: String): List<String> =
connection().use { connection ->
connection.prepareStatement(
"""
SELECT COLUMN_NAME
FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND INDEX_NAME = ?
ORDER BY SEQ_IN_INDEX
""".trimIndent(),
).use { statement ->
statement.setString(1, table)
statement.setString(2, index)
statement.executeQuery().use { result ->
buildList {
while (result.next()) add(result.getString("COLUMN_NAME"))
}
}
}
}
fun columnCollation(table: String, column: String): String? =
connection().use { connection ->
connection.prepareStatement(
"""
SELECT COLLATION_NAME
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME = ?
""".trimIndent(),
).use { statement ->
statement.setString(1, table)
statement.setString(2, column)
statement.executeQuery().use { result ->
if (result.next()) result.getString("COLLATION_NAME") else null
}
}
}
override fun close() {
factory.close()
container?.stop()
}
private fun execute(vararg sql: String) {
connection().use { connection ->
connection.createStatement().use { statement ->
sql.forEach { statement.executeUpdate(it.trimIndent()) }
}
}
}
private fun connection(): Connection =
DriverManager.getConnection(jdbcUrl, username, password)
}
private suspend fun AdminUsersService.collectLedger(
userId: UUID,
limit: Int,
query: AdminLedgerQuery = AdminLedgerQuery(),
): List<AdminUserLedgerEntryDto> {
val results = mutableListOf<AdminUserLedgerEntryDto>()
var cursor: String? = null
do {
val page = ledger(userId, limit, cursor, query)
results += page.items
cursor = page.nextCursor
} while (cursor != null)
return results
}
private fun startIntegrationDatabase(): AdminLedgerIntegrationDatabase {
val externalJdbcUrl = System.getenv("TEST_MYSQL_JDBC_URL")?.takeIf(String::isNotBlank)
if (externalJdbcUrl == null && !DockerClientFactory.instance().isDockerAvailable) {
throw TestAbortedException("Docker is unavailable; MySQL integration test skipped")
}
val mysql = if (externalJdbcUrl == null) {
AdminLedgerMySqlContainer("mysql:8.4")
.withDatabaseName("osg_admin_ledger_test")
.withUsername("test")
.withPassword("test")
.also(AdminLedgerMySqlContainer::start)
} else {
null
}
val jdbcUrl = externalJdbcUrl ?: requireNotNull(mysql).jdbcUrl
val username = System.getenv("TEST_MYSQL_USER")?.takeIf(String::isNotBlank)
?: mysql?.username
?: "root"
val password = System.getenv("TEST_MYSQL_PASSWORD")
?: mysql?.password
?: ""
val factory = DatabaseFactory(
DatabaseConfig(
jdbcUrl = jdbcUrl,
username = username,
password = password,
maximumPoolSize = 4,
),
)
factory.database
return AdminLedgerIntegrationDatabase(jdbcUrl, username, password, mysql, factory)
}
private class AdminLedgerMySqlContainer(image: String) :
MySQLContainer<AdminLedgerMySqlContainer>(image)
private fun Instant.toDatabaseTimestamp(): String =
LocalDateTime.ofInstant(this, ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSSSSS"))
@@ -7,7 +7,9 @@ import com.osglab.account.features.admin.users.models.AdminUserSummaryDto
import com.osglab.account.features.admin.stats.models.AdminUsageAggregateDto
import com.osglab.account.features.admin.models.AdminSortOrder
import com.osglab.account.features.admin.models.AdminTimeFilter
import com.osglab.account.features.admin.users.repositories.AdminLedgerSort
import com.osglab.account.features.admin.users.repositories.AdminLedgerType
import com.osglab.account.features.admin.users.repositories.AdminUsageType
import com.osglab.account.features.admin.users.repositories.AdminUserCursor
import com.osglab.account.features.admin.users.repositories.AdminLedgerQuery
import com.osglab.account.features.admin.users.repositories.AdminUserLedgerCursor
@@ -16,12 +18,14 @@ import com.osglab.account.features.admin.users.repositories.AdminUserStatus
import com.osglab.account.features.admin.users.repositories.AdminUsersRepository
import com.osglab.account.features.admin.users.services.AdminUserNotFoundException
import com.osglab.account.features.admin.users.services.AdminUsersService
import com.osglab.account.features.credits.domain.LedgerEntryType
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import java.time.Instant
import java.util.Base64
import java.util.UUID
class AdminUsersServiceTest : FunSpec({
@@ -270,7 +274,12 @@ class AdminUsersServiceTest : FunSpec({
ledgerEntry(lowerId, from, userId),
ledgerEntry(UUID.randomUUID(), from.minusNanos(1), userId),
ledgerEntry(UUID.randomUUID(), until, userId),
ledgerEntry(UUID.randomUUID(), from.plusSeconds(1), userId, type = "USAGE_SETTLE"),
ledgerEntry(
UUID.randomUUID(),
from.plusSeconds(1),
userId,
entryType = LedgerEntryType.USAGE_SETTLE,
),
)
val service = AdminUsersService(
PagingUsersRepository(emptyList(), ledger = mapOf(userId to entries)),
@@ -335,6 +344,164 @@ class AdminUsersServiceTest : FunSpec({
)
}
}
test("ledger combines exact type usage reference and time filters") {
val userId = UUID.randomUUID()
val referenceId = UUID.randomUUID()
val from = Instant.parse("2026-08-15T00:00:00Z")
val target = ledgerEntry(
id = UUID.randomUUID(),
createdAt = from.plusSeconds(10),
userId = userId,
entryType = LedgerEntryType.USAGE_SETTLE,
referenceId = referenceId,
usageType = AdminUsageType.HOTWORD,
)
val entries = listOf(
target,
ledgerEntry(
UUID.randomUUID(),
from.plusSeconds(10),
userId,
entryType = LedgerEntryType.USAGE_RESERVE,
referenceId = referenceId,
usageType = AdminUsageType.HOTWORD,
),
ledgerEntry(
UUID.randomUUID(),
from.plusSeconds(10),
userId,
entryType = LedgerEntryType.USAGE_SETTLE,
referenceId = UUID.randomUUID(),
usageType = AdminUsageType.HOTWORD,
),
ledgerEntry(
UUID.randomUUID(),
from.plusSeconds(10),
userId,
entryType = LedgerEntryType.USAGE_SETTLE,
referenceId = referenceId,
usageType = AdminUsageType.ASR,
),
ledgerEntry(
UUID.randomUUID(),
from.minusNanos(1),
userId,
entryType = LedgerEntryType.USAGE_SETTLE,
referenceId = referenceId,
usageType = AdminUsageType.HOTWORD,
),
)
val service = AdminUsersService(
PagingUsersRepository(emptyList(), ledger = mapOf(userId to entries)),
)
val page = service.ledger(
userId,
query = AdminLedgerQuery(
time = AdminTimeFilter(from, from.plusSeconds(60)),
type = AdminLedgerType.SETTLE,
entryType = LedgerEntryType.USAGE_SETTLE,
usageType = AdminUsageType.HOTWORD,
referenceId = referenceId,
),
)
page.items shouldBe listOf(target)
}
test("amount sorting paginates equal values stably in both directions") {
val userId = UUID.randomUUID()
val createdAt = Instant.parse("2026-08-15T00:00:00Z")
val lower = ledgerEntry(
UUID.fromString("11111111-1111-4111-8111-111111111111"),
createdAt,
userId,
amount = 10,
)
val higher = ledgerEntry(
UUID.fromString("22222222-2222-4222-8222-222222222222"),
createdAt,
userId,
amount = 10,
)
val smallest = ledgerEntry(UUID.randomUUID(), createdAt, userId, amount = -5)
val service = AdminUsersService(
PagingUsersRepository(emptyList(), ledger = mapOf(userId to listOf(higher, smallest, lower))),
)
val ascendingQuery = AdminLedgerQuery(
sort = AdminLedgerSort.AMOUNT,
order = AdminSortOrder.ASC,
)
val ascendingFirst = service.ledger(userId, limit = 2, query = ascendingQuery)
val ascendingSecond = service.ledger(
userId,
limit = 2,
cursor = ascendingFirst.nextCursor.shouldNotBeNull(),
query = ascendingQuery,
)
ascendingFirst.items shouldBe listOf(smallest, lower)
ascendingSecond.items shouldBe listOf(higher)
val descendingQuery = AdminLedgerQuery(
sort = AdminLedgerSort.AMOUNT,
order = AdminSortOrder.DESC,
)
val descendingFirst = service.ledger(userId, limit = 2, query = descendingQuery)
val descendingSecond = service.ledger(
userId,
limit = 2,
cursor = descendingFirst.nextCursor.shouldNotBeNull(),
query = descendingQuery,
)
descendingFirst.items shouldBe listOf(higher, lower)
descendingSecond.items shouldBe listOf(smallest)
}
test("createdAt accepts legacy v1 cursor while amount requires matching v2") {
val userId = UUID.randomUUID()
val createdAt = Instant.parse("2026-08-15T00:00:00Z")
val firstId = UUID.fromString("22222222-2222-4222-8222-222222222222")
val secondId = UUID.fromString("11111111-1111-4111-8111-111111111111")
val service = AdminUsersService(
PagingUsersRepository(
emptyList(),
ledger = mapOf(
userId to listOf(
ledgerEntry(firstId, createdAt, userId, amount = 20),
ledgerEntry(secondId, createdAt.minusSeconds(1), userId, amount = 10),
),
),
),
)
val legacy = Base64.getUrlEncoder().withoutPadding().encodeToString(
"v1|DESC|$createdAt|$firstId".toByteArray(),
)
service.ledger(userId, cursor = legacy).items shouldBe
listOf(ledgerEntry(secondId, createdAt.minusSeconds(1), userId, amount = 10))
shouldThrow<IllegalArgumentException> {
service.ledger(
userId,
cursor = legacy,
query = AdminLedgerQuery(sort = AdminLedgerSort.AMOUNT),
)
}
val amountPage = service.ledger(
userId,
limit = 1,
query = AdminLedgerQuery(sort = AdminLedgerSort.AMOUNT),
)
shouldThrow<IllegalArgumentException> {
service.ledger(
userId,
cursor = amountPage.nextCursor.shouldNotBeNull(),
query = AdminLedgerQuery(sort = AdminLedgerSort.CREATED_AT),
)
}
}
})
private class PagingUsersRepository(
@@ -393,7 +560,7 @@ private class PagingUsersRepository(
cursor == null || ledgerAfter(it, cursor, query.order)
}
.sortedWith(
ledgerComparator(query.order),
ledgerComparator(query.order, query.sort),
)
.take(limit)
@@ -408,7 +575,7 @@ private class PagingUsersRepository(
cursor == null || ledgerAfter(it, cursor, query.order)
}
.sortedWith(
ledgerComparator(query.order),
ledgerComparator(query.order, query.sort),
)
.take(limit)
}
@@ -441,18 +608,27 @@ private fun userAfter(
private fun AdminUserLedgerEntryDto.matches(query: AdminLedgerQuery): Boolean {
val instant = Instant.parse(createdAt)
val category = when (type) {
"USAGE_RESERVE" -> AdminLedgerType.RESERVE
"USAGE_SETTLE" -> AdminLedgerType.SETTLE
"USAGE_RELEASE", "USAGE_REFUND" -> AdminLedgerType.REFUND
"SIGNUP_TRIAL", "MANUAL_GRANT", "REFERRAL_INVITER", "REFERRAL_INVITEE",
"STOREKIT_PURCHASE", "SUBSCRIPTION_GRANT",
val category = when (entryType) {
LedgerEntryType.USAGE_RESERVE -> AdminLedgerType.RESERVE
LedgerEntryType.USAGE_SETTLE -> AdminLedgerType.SETTLE
LedgerEntryType.USAGE_RELEASE,
LedgerEntryType.USAGE_REFUND,
-> AdminLedgerType.REFUND
LedgerEntryType.SIGNUP_TRIAL,
LedgerEntryType.MANUAL_GRANT,
LedgerEntryType.REFERRAL_INVITER,
LedgerEntryType.REFERRAL_INVITEE,
LedgerEntryType.STOREKIT_PURCHASE,
LedgerEntryType.SUBSCRIPTION_GRANT,
-> AdminLedgerType.GRANT
else -> AdminLedgerType.ADJUSTMENT
}
return query.time.from?.let { instant >= it } != false &&
query.time.until?.let { instant < it } != false &&
(query.type == null || query.type == category)
(query.type == null || query.type == category) &&
(query.entryType == null || query.entryType == entryType) &&
(query.usageType == null || query.usageType.name.lowercase() == usageType) &&
(query.referenceId == null || query.referenceId.toString() == referenceId)
}
private fun ledgerAfter(
@@ -460,24 +636,32 @@ private fun ledgerAfter(
cursor: AdminUserLedgerCursor,
order: AdminSortOrder,
): Boolean {
val createdAt = Instant.parse(item.createdAt)
val primary = when (cursor.sort) {
AdminLedgerSort.CREATED_AT -> Instant.parse(item.createdAt).compareTo(requireNotNull(cursor.createdAt))
AdminLedgerSort.AMOUNT -> item.amountDelta.compareTo(requireNotNull(cursor.amount))
}
return if (order == AdminSortOrder.ASC) {
createdAt > cursor.createdAt ||
(createdAt == cursor.createdAt && item.id > cursor.ledgerEntryId.toString())
primary > 0 || (primary == 0 && item.id > cursor.ledgerEntryId.toString())
} else {
createdAt < cursor.createdAt ||
(createdAt == cursor.createdAt && item.id < cursor.ledgerEntryId.toString())
primary < 0 || (primary == 0 && item.id < cursor.ledgerEntryId.toString())
}
}
private fun ledgerComparator(order: AdminSortOrder): Comparator<AdminUserLedgerEntryDto> =
if (order == AdminSortOrder.ASC) {
compareBy<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
.thenBy(AdminUserLedgerEntryDto::id)
} else {
compareByDescending<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
.thenByDescending(AdminUserLedgerEntryDto::id)
private fun ledgerComparator(
order: AdminSortOrder,
sort: AdminLedgerSort = AdminLedgerSort.CREATED_AT,
): Comparator<AdminUserLedgerEntryDto> {
val ascending = when (sort) {
AdminLedgerSort.CREATED_AT ->
compareBy<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
.thenBy(AdminUserLedgerEntryDto::id)
AdminLedgerSort.AMOUNT ->
compareBy<AdminUserLedgerEntryDto>(AdminUserLedgerEntryDto::amountDelta)
.thenBy(AdminUserLedgerEntryDto::id)
}
return if (order == AdminSortOrder.ASC) ascending else ascending.reversed()
}
private fun summary(
id: UUID,
@@ -500,13 +684,17 @@ private fun ledgerEntry(
id: UUID,
createdAt: Instant,
userId: UUID = UUID.fromString("11111111-1111-4111-8111-111111111111"),
type: String = "MANUAL_GRANT",
entryType: LedgerEntryType = LedgerEntryType.MANUAL_GRANT,
amount: Long = 10,
referenceId: UUID? = null,
usageType: AdminUsageType? = null,
) = AdminUserLedgerEntryDto(
id = id.toString(),
userId = userId.toString(),
type = type,
amountDelta = 10,
entryType = entryType,
amountDelta = amount,
balanceAfter = 10,
referenceId = null,
referenceId = referenceId?.toString(),
createdAt = createdAt.toString(),
usageType = usageType?.name?.lowercase(),
)
@@ -41,6 +41,7 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
val reservations = mutableMapOf<UUID, CreditReservation>()
val rates = mutableMapOf<UUID, CreditRateVersion>()
val codes = mutableMapOf<UUID, ReferralCode>()
private val permanentCodeIds = mutableMapOf<UUID, UUID>()
val bindings = mutableMapOf<UUID, ReferralBinding>()
val storeKitPurchases = mutableMapOf<String, StoreKitCreditPurchase>()
val campaigns = mutableMapOf(
@@ -83,6 +84,7 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
val usageSnapshot = usageRecords.toList()
val reservationSnapshot = reservations.toMap()
val codeSnapshot = codes.toMap()
val permanentCodeSnapshot = permanentCodeIds.toMap()
val bindingSnapshot = bindings.toMap()
val budgetSnapshot = campaignBudgets.toMap()
val storeKitSnapshot = storeKitPurchases.toMap()
@@ -97,6 +99,7 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
usageRecords.replaceWith(usageSnapshot)
reservations.replaceWith(reservationSnapshot)
codes.replaceWith(codeSnapshot)
permanentCodeIds.replaceWith(permanentCodeSnapshot)
bindings.replaceWith(bindingSnapshot)
campaignBudgets.replaceWith(budgetSnapshot)
storeKitPurchases.replaceWith(storeKitSnapshot)
@@ -216,28 +219,26 @@ class TestBillingStore : BillingTransactionRunner, BillingUnitOfWork {
}
private inner class Referrals : ReferralsRepository {
override fun findCodeByOwner(ownerUserId: UUID, campaignId: UUID?): ReferralCode? =
codes.values
.filter { it.ownerUserId == ownerUserId }
.filter { campaignId == null || it.campaignId == campaignId }
.maxByOrNull(ReferralCode::createdAt)
override fun findPermanentCodeByOwner(ownerUserId: UUID): ReferralCode? =
permanentCodeIds[ownerUserId]?.let(codes::get)
override fun lockCodeByOwner(ownerUserId: UUID, campaignId: UUID): ReferralCode? =
findCodeByOwner(ownerUserId, campaignId)
override fun claimPermanentCode(candidate: ReferralCode): ReferralCode? {
findPermanentCodeByOwner(candidate.ownerUserId)?.let { return it }
val storedCode = codes.values.singleOrNull {
it.ownerUserId == candidate.ownerUserId &&
it.campaignId == candidate.campaignId
} ?: run {
if (findCode(candidate.code) != null) return null
codes[candidate.id] = candidate
candidate
}
permanentCodeIds.putIfAbsent(candidate.ownerUserId, storedCode.id)
return findPermanentCodeByOwner(candidate.ownerUserId)
}
override fun findCode(code: String): ReferralCode? =
codes.values.singleOrNull { it.code == code }
override fun insertCodeIfAbsent(code: ReferralCode): Boolean {
if (findCodeByOwner(code.ownerUserId, code.campaignId) != null ||
findCode(code.code) != null
) {
return false
}
codes[code.id] = code
return true
}
override fun findCampaign(id: UUID): ReferralCampaign? = campaigns[id]
override fun listActiveCampaigns(at: Instant): List<ReferralCampaign> =
@@ -91,7 +91,7 @@ class InviteWebRoutesTest {
response.status shouldBe HttpStatusCode.NotFound
response.bodyAsText() shouldBe
"邀请链接无效或已失效 / This invitation link is invalid or expired"
"邀请链接无效 / This invitation link is invalid"
}
@Test
@@ -0,0 +1,87 @@
package com.osglab.account.features.referrals
import com.osglab.account.features.credits.routes.AuthenticatedUserExtractor
import com.osglab.account.features.referrals.domain.ReferralBinding
import com.osglab.account.features.referrals.domain.ReferralCampaign
import com.osglab.account.features.referrals.domain.ReferralCode
import com.osglab.account.features.referrals.routes.referralRoutes
import com.osglab.account.features.referrals.services.ReferralOperations
import com.osglab.account.features.referrals.services.ReferralProfile
import io.kotest.matchers.shouldBe
import io.kotest.matchers.string.shouldContain
import io.ktor.client.request.get
import io.ktor.client.request.post
import io.ktor.client.statement.bodyAsText
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.install
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.routing.routing
import io.ktor.server.testing.testApplication
import kotlinx.serialization.json.Json
import java.time.Instant
import java.util.UUID
import kotlin.test.Test
class ReferralRoutesTest {
@Test
fun `profile and compatibility endpoint distribute the same permanent invitation URL`() =
testApplication {
val userId = UUID.fromString("10000000-0000-0000-0000-000000000020")
val code = ReferralCode(
id = UUID.fromString("20000000-0000-0000-0000-000000000020"),
ownerUserId = userId,
ownerIdentityFingerprint = "a".repeat(64),
code = "AbCdEf0123456789_-AbCd",
createdAt = Instant.parse("2026-08-20T00:00:00Z"),
)
val operations = FixedReferralOperations(code)
application {
install(ContentNegotiation) { json(Json { explicitNulls = false }) }
routing {
referralRoutes(
service = operations,
inviteBaseUrl = "https://osglab.com/i",
authenticatedUser = AuthenticatedUserExtractor { userId },
)
}
}
val profile = client.get("/v1/referrals/me")
val compatibilityCode = client.post("/v1/referrals/code")
profile.status shouldBe HttpStatusCode.OK
compatibilityCode.status shouldBe HttpStatusCode.OK
profile.bodyAsText() shouldContain
""""inviteUrl":"https://osglab.com/i/AbCdEf0123456789_-AbCd""""
compatibilityCode.bodyAsText() shouldContain
""""inviteUrl":"https://osglab.com/i/AbCdEf0123456789_-AbCd""""
operations.codeRequests shouldBe 2
}
}
private class FixedReferralOperations(
private val code: ReferralCode,
) : ReferralOperations {
var codeRequests = 0
override suspend fun getOrCreateCode(ownerUserId: UUID): ReferralCode {
codeRequests += 1
return code
}
override suspend fun getOrCreateCode(ownerUserId: UUID, campaignId: UUID?): ReferralCode =
getOrCreateCode(ownerUserId)
override suspend fun bind(inviteeUserId: UUID, rawCode: String): ReferralBinding =
error("Not used")
override suspend fun getProfile(userId: UUID): ReferralProfile {
codeRequests += 1
return ReferralProfile(code, binding = null)
}
override suspend fun listActiveCampaigns(): List<ReferralCampaign> = emptyList()
override suspend fun listInvited(userId: UUID, limit: Int): List<ReferralBinding> = emptyList()
}
@@ -1,6 +1,7 @@
package com.osglab.account.features.referrals
import com.osglab.account.features.credits.TestBillingStore
import com.osglab.account.features.referrals.domain.DEFAULT_REFERRAL_CAMPAIGN_ID
import com.osglab.account.features.referrals.domain.InviteCodeGenerator
import com.osglab.account.features.referrals.domain.ReferralBindingRules
import com.osglab.account.features.referrals.domain.ReferralConflict
@@ -15,6 +16,9 @@ import com.osglab.account.features.referrals.services.UserRegistrationTimeProvid
import io.kotest.assertions.throwables.shouldThrow
import io.kotest.core.spec.style.FunSpec
import io.kotest.matchers.shouldBe
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import java.time.Clock
import java.time.Duration
import java.time.Instant
@@ -31,13 +35,33 @@ class ReferralServiceTest : FunSpec({
val service = referralService(store, now) { now.minus(Duration.ofDays(1)) }
val first = service.getOrCreateCode(owner)
val second = service.getOrCreateCode(owner)
val laterCampaignId = UUID.randomUUID()
store.campaigns[laterCampaignId] = referralCampaign(
id = laterCampaignId,
startsAt = now.minusSeconds(60),
)
val second = service.getOrCreateCode(owner, laterCampaignId)
second shouldBe first
first.code.length shouldBe 22
store.codes.size shouldBe 1
}
test("concurrent provisioning claims one permanent code") {
val store = TestBillingStore()
val owner = UUID.randomUUID()
val service = referralService(store, now) { now.minus(Duration.ofDays(1)) }
val codes = coroutineScope {
List(8) {
async { service.getOrCreateCode(owner) }
}.awaitAll()
}
codes.map(ReferralCode::id).distinct().size shouldBe 1
store.codes.size shouldBe 1
}
test("profile lookup automatically provisions a stable invitation code") {
val store = TestBillingStore()
val owner = UUID.randomUUID()
@@ -47,10 +71,61 @@ class ReferralServiceTest : FunSpec({
val second = service.getProfile(owner)
first.code shouldBe second.code
first.code?.code?.length shouldBe 22
first.code.code.length shouldBe 22
store.codes.size shouldBe 1
}
test("permanent code provisioning does not depend on an active reward campaign") {
val store = TestBillingStore()
store.campaigns[DEFAULT_REFERRAL_CAMPAIGN_ID] =
store.campaigns.getValue(DEFAULT_REFERRAL_CAMPAIGN_ID).copy(enabled = false)
val owner = UUID.randomUUID()
val service = referralService(store, now) { now.minus(Duration.ofDays(1)) }
val code = service.getOrCreateCode(owner)
code.ownerUserId shouldBe owner
store.codes.values.single() shouldBe code
}
test("an existing permanent code is returned without reprovisioning identity") {
val store = TestBillingStore()
val owner = UUID.randomUUID()
val original = referralService(store, now) { now.minus(Duration.ofDays(1)) }
.getOrCreateCode(owner)
val identityUnavailable = referralService(
store = store,
now = now,
riskIdentity = { null },
registeredAt = { now.minus(Duration.ofDays(1)) },
)
identityUnavailable.getOrCreateCode(owner) shouldBe original
store.codes.size shouldBe 1
}
test("a permanent code remains redeemable after the reward campaign changes") {
val store = TestBillingStore()
val inviter = UUID.randomUUID()
val invitee = UUID.randomUUID()
val service = referralService(store, now) { now.minus(Duration.ofDays(1)) }
val code = service.getOrCreateCode(inviter)
store.campaigns[DEFAULT_REFERRAL_CAMPAIGN_ID] =
store.campaigns.getValue(DEFAULT_REFERRAL_CAMPAIGN_ID).copy(enabled = false)
val currentCampaignId = UUID.randomUUID()
store.campaigns[currentCampaignId] = referralCampaign(
id = currentCampaignId,
startsAt = now.minusSeconds(60),
)
store.campaignBudgets[currentCampaignId] =
ReferralCampaignBudget(currentCampaignId, 0, 0, now)
val binding = service.bind(invitee, code.code)
binding.codeId shouldBe code.id
binding.campaignId shouldBe currentCampaignId
}
test("an account binds once and repeated same binding is idempotent") {
val store = TestBillingStore()
val inviter = UUID.randomUUID()
@@ -195,7 +270,7 @@ class ReferralServiceTest : FunSpec({
private fun referralService(
store: TestBillingStore,
now: Instant,
riskIdentity: (UUID) -> ReferralRiskIdentity = { userId ->
riskIdentity: (UUID) -> ReferralRiskIdentity? = { userId ->
ReferralRiskIdentity(fingerprint(userId), restricted = false)
},
registeredAt: (UUID) -> Instant,
@@ -215,5 +290,19 @@ private fun referralService(
)
}
private fun referralCampaign(id: UUID, startsAt: Instant): ReferralCampaign =
ReferralCampaign(
id = id,
name = "Current campaign",
startsAt = startsAt,
endsAt = null,
bindingWindowSeconds = Duration.ofDays(7).seconds,
inviterRewardCredits = 10,
inviteeRewardCredits = 10,
maxRewardedBindings = null,
budgetCredits = null,
enabled = true,
)
private fun fingerprint(userId: UUID): String =
userId.toString().replace("-", "").repeat(2)