Enhance ledger operations and referral lifecycle
Add traceable ledger filtering and permanent referral codes so operators can investigate credit activity without weakening immutable accounting guarantees.
This commit is contained in:
@@ -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
|
||||
|
||||
+337
-82
@@ -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(),
|
||||
)
|
||||
|
||||
|
||||
+56
-12
@@ -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)
|
||||
}
|
||||
|
||||
+61
-29
@@ -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()
|
||||
|
||||
+10
-14
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
+6
-4
@@ -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",
|
||||
|
||||
+588
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user