Show latest credit ledger in admin

Expose a paginated global ledger timeline and load it automatically so operators can see recent credit activity without first locating a user.
This commit is contained in:
Rocky
2026-08-19 18:05:54 +08:00
parent 58445dd880
commit 75c046d91d
15 changed files with 356 additions and 34 deletions
@@ -237,6 +237,33 @@ fun Route.adminApiRoutes(
}
}
get("/credits/ledger") {
if (
call.requireRole(
sessionService,
setOf(AdminRole.SUPER_ADMIN, AdminRole.SUPPORT),
) == null
) return@get
val limit = call.pageLimit(maximum = 100, default = 100) ?: run {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
return@get
}
try {
val page = usersService.latestLedger(
limit = limit,
cursor = call.request.queryParameters["cursor"],
)
call.respond(
PageResponse(
page.items.map(AdminUserLedgerEntryDto::toLedgerResponse),
page.nextCursor,
),
)
} catch (_: IllegalArgumentException) {
call.respond(HttpStatusCode.BadRequest, AdminErrorResponse("VALIDATION_ERROR"))
}
}
post("/credits/grants") {
val principal = call.requireMutationPrincipal(config, sessionService) ?: return@post
if (principal.role != AdminRole.SUPER_ADMIN) {
@@ -667,6 +694,7 @@ private fun AdminUserDetailDto.toUserDetailResponse(): AdminUserDetailResponse =
private fun AdminUserLedgerEntryDto.toLedgerResponse(): AdminLedgerResponse =
AdminLedgerResponse(
entryId = id,
userId = userId,
type = when (type) {
"USAGE_RESERVE" -> "reserve"
"USAGE_SETTLE" -> "settle"
@@ -864,6 +892,7 @@ private data class AdminUserDetailResponse(
@Serializable
private data class AdminLedgerResponse(
val entryId: String,
val userId: String,
val type: String,
val amount: Long,
val balanceAfter: Long,
@@ -26,6 +26,7 @@ data class AdminUserPageDto(
@Serializable
data class AdminUserLedgerEntryDto(
val id: String,
val userId: String,
val type: String,
val amountDelta: Long,
val balanceAfter: Long,
@@ -47,6 +47,11 @@ interface AdminUsersRepository {
limit: Int,
cursor: AdminUserLedgerCursor?,
): List<AdminUserLedgerEntryDto>
suspend fun listLatestLedger(
limit: Int,
cursor: AdminUserLedgerCursor?,
): List<AdminUserLedgerEntryDto>
}
class ExposedAdminUsersRepository(
@@ -173,6 +178,28 @@ class ExposedAdminUsersRepository(
.limit(limit)
.map { it.toUserLedgerRow().toDto() }
}
override suspend fun listLatestLedger(
limit: Int,
cursor: AdminUserLedgerCursor?,
): List<AdminUserLedgerEntryDto> = databaseFactory.query {
val query = AdminUsersCreditLedgerTable.selectAll()
if (cursor != null) {
query.where {
(AdminUsersCreditLedgerTable.createdAt less cursor.createdAt) or
(
(AdminUsersCreditLedgerTable.createdAt eq cursor.createdAt) and
(AdminUsersCreditLedgerTable.id less cursor.ledgerEntryId.toString())
)
}
}
query.orderBy(
AdminUsersCreditLedgerTable.createdAt to SortOrder.DESC,
AdminUsersCreditLedgerTable.id to SortOrder.DESC,
)
.limit(limit)
.map { it.toUserLedgerRow().toDto() }
}
}
private data class UserSupportRows(
@@ -324,6 +351,7 @@ private fun ResultRow.toUserLedgerRow() = UserLedgerRow(
private fun UserLedgerRow.toDto() = AdminUserLedgerEntryDto(
id = id.toString(),
userId = userId.toString(),
type = type.name,
amountDelta = amountDelta,
balanceAfter = balanceAfter,
@@ -1,6 +1,7 @@
package com.osglab.account.features.admin.users.services
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.AdminUserCursor
@@ -67,11 +68,27 @@ class AdminUsersService(
userId: UUID,
limit: Int = 50,
cursor: String? = null,
): AdminUserLedgerPageDto {
return ledgerPage(limit, cursor) { pageSize, decodedCursor ->
if (!repository.exists(userId)) throw AdminUserNotFoundException()
repository.listLedger(userId, pageSize, decodedCursor)
}
}
suspend fun latestLedger(
limit: Int = 100,
cursor: String? = null,
): AdminUserLedgerPageDto =
ledgerPage(limit, cursor, repository::listLatestLedger)
private suspend fun ledgerPage(
limit: Int,
cursor: String?,
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)
if (!repository.exists(userId)) throw AdminUserNotFoundException()
val results = repository.listLedger(userId, limit + 1, decodedCursor)
val results = load(limit + 1, decodedCursor)
val hasMore = results.size > limit
val items = results.take(limit)
val nextCursor = if (hasMore) {