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
@@ -84,10 +84,14 @@ class DeploymentConsistencyTest : FunSpec({
val referralMigration = root.read(
"src/main/resources/db/migration/V12__align_referral_rewards.sql",
)
val ledgerTimelineMigration = root.read(
"src/main/resources/db/migration/V13__credit_ledger_global_timeline.sql",
)
profileMigration shouldContain "encrypted_display_name MEDIUMTEXT NOT NULL"
profileMigration shouldContain "REFERENCES accounts (id) ON DELETE CASCADE"
referralMigration shouldContain "inviter_reward_credits = 1000"
referralMigration shouldContain "invitee_reward_credits = 1000"
ledgerTimelineMigration shouldContain "ON credit_ledger (created_at, id)"
listOf(
root.read("src/main/resources/application.yaml"),
@@ -234,6 +238,7 @@ private val EXPECTED_PUBLIC_PATHS = setOf(
"/v1/admin/users",
"/v1/admin/users/{userId}",
"/v1/admin/users/{userId}/ledger",
"/v1/admin/credits/ledger",
"/v1/admin/credits/grants",
"/v1/admin/operators/summary",
"/v1/admin/operators",
@@ -51,7 +51,7 @@ class SmokeDeploymentTest : FunSpec({
test("runtime grants cover every migrated table without mutable history privileges") {
val grants = root.read("deploy/smoke/runtime-grants.sql")
val migrationTables = (1..12)
val migrationTables = (1..13)
.flatMap { version ->
val migration = Files.list(root.resolve("src/main/resources/db/migration")).use { paths ->
paths.filter { it.fileName.toString().startsWith("V${version}__") }
@@ -12,6 +12,8 @@ import com.osglab.account.features.admin.services.AdminOperatorErrorCode
import com.osglab.account.features.admin.services.AdminOperatorException
import com.osglab.account.features.admin.services.AdminSessionService
import com.osglab.account.features.admin.stats.services.AdminStatsService
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.services.AdminUsersService
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.domain.CreditNotFound
@@ -199,6 +201,40 @@ class AdminRoutesTest {
response.bodyAsText() shouldContain """"code":"INSUFFICIENT_PERMISSION""""
}
@Test
fun `support can load latest credit ledger across users`() = testApplication {
val usersService = mockk<AdminUsersService>()
coEvery { usersService.latestLedger(100, null) } returns AdminUserLedgerPageDto(
items = listOf(
AdminUserLedgerEntryDto(
id = "ffffffff-ffff-ffff-ffff-ffffffffffff",
userId = "11111111-1111-4111-8111-111111111111",
type = "USAGE_SETTLE",
amountDelta = -18,
balanceAfter = 102,
referenceId = null,
createdAt = "2026-08-19T09:00:00Z",
),
),
nextCursor = 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)
response.bodyAsText() shouldContain """"userId":"11111111-1111-4111-8111-111111111111""""
response.bodyAsText() shouldContain """"reasonCode":"USAGE_SETTLE""""
}
@Test
fun `operator creation maps normalized username conflict to 409`() = testApplication {
val sessionService = sessionFixture(AdminRole.SUPER_ADMIN)
@@ -256,6 +292,7 @@ private fun io.ktor.server.application.Application.installAdminTestRoutes(
grantService: AdminGrantService = mockk(relaxed = true),
operatorService: AdminOperatorService = mockk(relaxed = true),
auditService: AdminAuditService = mockk(relaxed = true),
usersService: AdminUsersService = mockk(relaxed = true),
) {
install(ContentNegotiation) {
json(Json { explicitNulls = false })
@@ -276,7 +313,7 @@ private fun io.ktor.server.application.Application.installAdminTestRoutes(
authService = authService,
sessionService = sessionService,
statsService = mockk<AdminStatsService>(relaxed = true),
usersService = mockk<AdminUsersService>(relaxed = true),
usersService = usersService,
grantService = grantService,
operatorService = operatorService,
auditService = auditService,
@@ -109,6 +109,42 @@ class AdminUsersServiceTest : FunSpec({
second.nextCursor shouldBe null
}
test("latest ledger combines users in newest-first order with pagination") {
val firstUserId = UUID.randomUUID()
val secondUserId = UUID.randomUUID()
val newest = ledgerEntry(
UUID.fromString("ffffffff-ffff-ffff-ffff-ffffffffffff"),
Instant.parse("2026-08-15T03:00:00Z"),
firstUserId,
)
val middle = ledgerEntry(
UUID.fromString("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee"),
Instant.parse("2026-08-15T02:00:00Z"),
secondUserId,
)
val oldest = ledgerEntry(
UUID.fromString("dddddddd-dddd-dddd-dddd-dddddddddddd"),
Instant.parse("2026-08-15T01:00:00Z"),
firstUserId,
)
val service = AdminUsersService(
PagingUsersRepository(
users = emptyList(),
ledger = mapOf(
firstUserId to listOf(newest, oldest),
secondUserId to listOf(middle),
),
),
)
val first = service.latestLedger(limit = 2)
val second = service.latestLedger(limit = 2, cursor = first.nextCursor.shouldNotBeNull())
first.items shouldBe listOf(newest, middle)
second.items shouldBe listOf(oldest)
second.nextCursor shouldBe null
}
test("invalid ledger cursor throws a stable illegal argument exception") {
val service = AdminUsersService(PagingUsersRepository(emptyList()))
@@ -241,6 +277,26 @@ private class PagingUsersRepository(
.thenByDescending(AdminUserLedgerEntryDto::id),
)
.take(limit)
override suspend fun listLatestLedger(
limit: Int,
cursor: AdminUserLedgerCursor?,
): List<AdminUserLedgerEntryDto> =
ledger.values.flatten()
.filter {
val createdAt = Instant.parse(it.createdAt)
cursor == null ||
createdAt < cursor.createdAt ||
(
createdAt == cursor.createdAt &&
it.id < cursor.ledgerEntryId.toString()
)
}
.sortedWith(
compareByDescending<AdminUserLedgerEntryDto> { Instant.parse(it.createdAt) }
.thenByDescending(AdminUserLedgerEntryDto::id),
)
.take(limit)
}
private fun summary(id: UUID, createdAt: Instant) = AdminUserSummaryDto(
@@ -259,8 +315,10 @@ private fun summary(id: UUID, createdAt: Instant) = AdminUserSummaryDto(
private fun ledgerEntry(
id: UUID,
createdAt: Instant,
userId: UUID = UUID.fromString("11111111-1111-4111-8111-111111111111"),
) = AdminUserLedgerEntryDto(
id = id.toString(),
userId = userId.toString(),
type = "MANUAL_GRANT",
amountDelta = 10,
balanceAfter = 10,