Add StoreKit history and modernize admin console
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

Expose ledger-backed cross-device purchase history while shipping the tested React admin redesign in the same reproducible deployment revision.
This commit is contained in:
Rocky
2026-08-19 22:13:13 +08:00
parent 11ec34dacb
commit 231c5040a5
51 changed files with 6484 additions and 4236 deletions
@@ -295,7 +295,7 @@ fun Application.module() {
accountRoutes(koin.get())
creditRoutes(koin.get(), koin.get())
referralRoutes(koin.get(), koin.get())
storeKitRoutes(koin.get(), koin.get())
storeKitRoutes(koin.get())
}
rateLimit(GATEWAY_RATE_LIMIT) {
configureGatewayRoutes(
@@ -55,6 +55,24 @@ data class StoreKitPurchaseResult(
val replayed: Boolean,
)
data class StoreKitTransactionCursor(
val purchasedAt: Instant,
val transactionId: String,
)
data class CreditedStoreKitTransaction(
val transactionId: String,
val productId: String,
val creditsGranted: Long,
val balanceAfter: Long,
val purchasedAt: Instant,
)
data class StoreKitTransactionPage(
val items: List<CreditedStoreKitTransaction>,
val nextCursor: String?,
)
sealed class StoreKitException(message: String) : RuntimeException(message)
class StoreKitUnavailable : StoreKitException("StoreKit credit purchases are unavailable")
@@ -1,8 +1,13 @@
package com.osglab.account.features.storekit.models
import com.osglab.account.features.storekit.domain.CreditedStoreKitTransaction
import com.osglab.account.features.storekit.domain.StoreKitProduct
import com.osglab.account.features.storekit.domain.StoreKitPurchaseResult
import com.osglab.account.features.storekit.domain.StoreKitTransactionPage
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonPrimitive
@Serializable
data class StoreKitProductDto(
@@ -40,3 +45,41 @@ data class StoreKitPurchaseResponse(
}
}
@Serializable
data class StoreKitTransactionHistoryItemResponse(
val transactionId: String,
val productId: String,
val creditsGranted: Long,
val balanceAfter: Long,
val purchasedAt: String,
val status: String,
) {
companion object {
fun fromDomain(
transaction: CreditedStoreKitTransaction,
): StoreKitTransactionHistoryItemResponse =
StoreKitTransactionHistoryItemResponse(
transactionId = transaction.transactionId,
productId = transaction.productId,
creditsGranted = transaction.creditsGranted,
balanceAfter = transaction.balanceAfter,
purchasedAt = transaction.purchasedAt.toString(),
status = "credited",
)
}
}
@Serializable
data class StoreKitTransactionHistoryResponse(
val items: List<StoreKitTransactionHistoryItemResponse>,
val nextCursor: JsonElement,
) {
companion object {
fun fromDomain(page: StoreKitTransactionPage): StoreKitTransactionHistoryResponse =
StoreKitTransactionHistoryResponse(
items = page.items.map(StoreKitTransactionHistoryItemResponse::fromDomain),
nextCursor = page.nextCursor?.let(::JsonPrimitive) ?: JsonNull,
)
}
}
@@ -1,16 +1,26 @@
package com.osglab.account.features.storekit.repositories
import com.osglab.account.features.credits.domain.LedgerEntryType
import com.osglab.account.features.storekit.domain.CreditedStoreKitTransaction
import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase
import com.osglab.account.features.storekit.domain.StoreKitEnvironment
import com.osglab.account.features.storekit.domain.StoreKitTransactionCursor
import java.util.UUID
import org.jetbrains.exposed.v1.core.*
import org.jetbrains.exposed.v1.javatime.timestamp
import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.select
import org.jetbrains.exposed.v1.jdbc.selectAll
interface StoreKitRepository {
fun findByTransactionId(transactionId: String): StoreKitCreditPurchase?
fun listCreditedTransactions(
userId: UUID,
limit: Int,
before: StoreKitTransactionCursor?,
): List<CreditedStoreKitTransaction>
fun insert(purchase: StoreKitCreditPurchase)
}
@@ -22,6 +32,60 @@ object ExposedStoreKitRepository : StoreKitRepository {
.singleOrNull()
?.toStoreKitCreditPurchase()
override fun listCreditedTransactions(
userId: UUID,
limit: Int,
before: StoreKitTransactionCursor?,
): List<CreditedStoreKitTransaction> {
val accountId = userId.toString()
val query = StoreKitCreditPurchases
.innerJoin(
otherTable = StoreKitPurchaseLedger,
onColumn = { ledgerEntryId },
otherColumn = { id },
)
.select(
StoreKitCreditPurchases.transactionId,
StoreKitCreditPurchases.productId,
StoreKitCreditPurchases.creditsGranted,
StoreKitCreditPurchases.purchasedAt,
StoreKitPurchaseLedger.balanceAfter,
)
query.where {
val accountAndCredited =
(StoreKitCreditPurchases.userId eq accountId) and
(StoreKitPurchaseLedger.userId eq accountId) and
(StoreKitPurchaseLedger.entryType eq LedgerEntryType.STOREKIT_PURCHASE) and
(
StoreKitPurchaseLedger.amountDelta eq
StoreKitCreditPurchases.creditsGranted
) and
(StoreKitPurchaseLedger.referenceId eq StoreKitCreditPurchases.id)
if (before == null) {
accountAndCredited
} else {
accountAndCredited and
(
(StoreKitCreditPurchases.purchasedAt less before.purchasedAt) or
(
(StoreKitCreditPurchases.purchasedAt eq before.purchasedAt) and
(
StoreKitCreditPurchases.transactionId less
before.transactionId
)
)
)
}
}
return query
.orderBy(
StoreKitCreditPurchases.purchasedAt to SortOrder.DESC,
StoreKitCreditPurchases.transactionId to SortOrder.DESC,
)
.limit(limit)
.map(ResultRow::toCreditedStoreKitTransaction)
}
override fun insert(purchase: StoreKitCreditPurchase) {
StoreKitCreditPurchases.insert {
it[id] = purchase.id.toString()
@@ -59,6 +123,17 @@ private object StoreKitCreditPurchases : Table("storekit_credit_purchases") {
override val primaryKey = PrimaryKey(id)
}
private object StoreKitPurchaseLedger : Table("credit_ledger") {
val id = varchar("id", 36)
val userId = varchar("user_id", 36)
val entryType = enumerationByName<LedgerEntryType>("entry_type", 32)
val amountDelta = long("amount_delta")
val balanceAfter = long("balance_after")
val referenceId = varchar("reference_id", 36).nullable()
override val primaryKey = PrimaryKey(id)
}
private fun ResultRow.toStoreKitCreditPurchase(): StoreKitCreditPurchase =
StoreKitCreditPurchase(
id = UUID.fromString(this[StoreKitCreditPurchases.id]),
@@ -75,3 +150,12 @@ private fun ResultRow.toStoreKitCreditPurchase(): StoreKitCreditPurchase =
signedAt = this[StoreKitCreditPurchases.signedAt],
createdAt = this[StoreKitCreditPurchases.createdAt],
)
private fun ResultRow.toCreditedStoreKitTransaction(): CreditedStoreKitTransaction =
CreditedStoreKitTransaction(
transactionId = this[StoreKitCreditPurchases.transactionId],
productId = this[StoreKitCreditPurchases.productId],
creditsGranted = this[StoreKitCreditPurchases.creditsGranted],
balanceAfter = this[StoreKitPurchaseLedger.balanceAfter],
purchasedAt = this[StoreKitCreditPurchases.purchasedAt],
)
@@ -2,6 +2,7 @@ package com.osglab.account.features.storekit.routes
import com.osglab.account.common.api.ApiError
import com.osglab.account.common.api.ApiErrorResponse
import com.osglab.account.common.security.SESSION_AUTH_NAME
import com.osglab.account.features.credits.domain.CreditConflict
import com.osglab.account.features.credits.routes.AuthenticatedUserExtractor
import com.osglab.account.features.credits.routes.JwtSubjectUserExtractor
@@ -12,8 +13,10 @@ import com.osglab.account.features.storekit.domain.StoreKitVerificationFailed
import com.osglab.account.features.storekit.models.StoreKitProductDto
import com.osglab.account.features.storekit.models.StoreKitPurchaseResponse
import com.osglab.account.features.storekit.models.StoreKitSubmitRequest
import com.osglab.account.features.storekit.models.StoreKitTransactionHistoryResponse
import com.osglab.account.features.storekit.services.StoreKitService
import io.ktor.http.HttpStatusCode
import io.ktor.server.auth.authenticate
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
@@ -25,64 +28,102 @@ fun Route.storeKitRoutes(
service: StoreKitService,
authenticatedUser: AuthenticatedUserExtractor = JwtSubjectUserExtractor,
) {
route("/v1/storekit") {
get("/products") {
val userId = authenticatedUser.extract(call)
if (userId == null) {
call.respond(
HttpStatusCode.Unauthorized,
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
)
return@get
authenticate(SESSION_AUTH_NAME) {
route("/v1/storekit") {
get("/products") {
val userId = authenticatedUser.extract(call)
if (userId == null) {
call.respond(
HttpStatusCode.Unauthorized,
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
)
return@get
}
call.respond(service.products().map(StoreKitProductDto::fromDomain))
}
call.respond(service.products().map(StoreKitProductDto::fromDomain))
}
post("/transactions") {
val userId = authenticatedUser.extract(call)
if (userId == null) {
call.respond(
HttpStatusCode.Unauthorized,
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
)
return@post
get("/transactions") {
val userId = authenticatedUser.extract(call)
if (userId == null) {
call.respond(
HttpStatusCode.Unauthorized,
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
)
return@get
}
try {
if (call.request.queryParameters.contains("accountId")) {
throw InvalidStoreKitRequest("accountId is not accepted")
}
val rawLimit = call.request.queryParameters["limit"]
val limit = rawLimit?.toIntOrNull()
?: if (rawLimit == null) {
50
} else {
throw InvalidStoreKitRequest("limit must be an integer")
}
call.respond(
StoreKitTransactionHistoryResponse.fromDomain(
service.listTransactions(
userId = userId,
limit = limit,
cursor = call.request.queryParameters["cursor"],
)
)
)
} catch (_: InvalidStoreKitRequest) {
call.respond(
HttpStatusCode.BadRequest,
ApiErrorResponse(ApiError("invalid_request", "The transaction request is invalid")),
)
}
}
val request = call.receive<StoreKitSubmitRequest>()
try {
call.respond(
HttpStatusCode.OK,
StoreKitPurchaseResponse.fromDomain(
service.submit(userId, request.signedTransaction)
),
)
} catch (_: StoreKitUnavailable) {
call.respond(
HttpStatusCode.ServiceUnavailable,
ApiErrorResponse(
ApiError("external_service_unavailable", "Credit purchases are unavailable")
),
)
} catch (_: InvalidStoreKitRequest) {
call.respond(
HttpStatusCode.BadRequest,
ApiErrorResponse(ApiError("invalid_request", "The transaction request is invalid")),
)
} catch (_: StoreKitVerificationFailed) {
call.respond(
HttpStatusCode.UnprocessableEntity,
ApiErrorResponse(
ApiError("transaction_invalid", "The App Store transaction is invalid")
),
)
} catch (_: StoreKitPurchaseConflict) {
call.respond(
HttpStatusCode.Conflict,
ApiErrorResponse(ApiError("conflict", "The App Store transaction conflicts")),
)
} catch (_: CreditConflict) {
call.respond(
HttpStatusCode.Conflict,
ApiErrorResponse(ApiError("conflict", "The App Store transaction conflicts")),
)
post("/transactions") {
val userId = authenticatedUser.extract(call)
if (userId == null) {
call.respond(
HttpStatusCode.Unauthorized,
ApiErrorResponse(ApiError("unauthorized", "Authentication required")),
)
return@post
}
val request = call.receive<StoreKitSubmitRequest>()
try {
call.respond(
HttpStatusCode.OK,
StoreKitPurchaseResponse.fromDomain(
service.submit(userId, request.signedTransaction)
),
)
} catch (_: StoreKitUnavailable) {
call.respond(
HttpStatusCode.ServiceUnavailable,
ApiErrorResponse(
ApiError("external_service_unavailable", "Credit purchases are unavailable")
),
)
} catch (_: InvalidStoreKitRequest) {
call.respond(
HttpStatusCode.BadRequest,
ApiErrorResponse(ApiError("invalid_request", "The transaction request is invalid")),
)
} catch (_: StoreKitVerificationFailed) {
call.respond(
HttpStatusCode.UnprocessableEntity,
ApiErrorResponse(
ApiError("transaction_invalid", "The App Store transaction is invalid")
),
)
} catch (_: StoreKitPurchaseConflict) {
call.respond(
HttpStatusCode.Conflict,
ApiErrorResponse(ApiError("conflict", "The App Store transaction conflicts")),
)
} catch (_: CreditConflict) {
call.respond(
HttpStatusCode.Conflict,
ApiErrorResponse(ApiError("conflict", "The App Store transaction conflicts")),
)
}
}
}
}
@@ -9,13 +9,17 @@ import com.osglab.account.features.storekit.domain.StoreKitCreditPurchase
import com.osglab.account.features.storekit.domain.StoreKitProduct
import com.osglab.account.features.storekit.domain.StoreKitPurchaseConflict
import com.osglab.account.features.storekit.domain.StoreKitPurchaseResult
import com.osglab.account.features.storekit.domain.StoreKitTransactionCursor
import com.osglab.account.features.storekit.domain.StoreKitTransactionPage
import com.osglab.account.features.storekit.domain.StoreKitUnavailable
import com.osglab.account.features.storekit.domain.VerifiedStoreKitTransaction
import com.osglab.account.features.storekit.verification.StoreKitTransactionVerifier
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.time.Clock
import java.time.Duration
import java.time.Instant
import java.util.Base64
import java.util.UUID
class StoreKitService(
@@ -33,6 +37,33 @@ class StoreKitService(
fun products(): List<StoreKitProduct> = productsById.values.sortedBy(StoreKitProduct::credits)
suspend fun listTransactions(
userId: UUID,
limit: Int = DEFAULT_PAGE_SIZE,
cursor: String? = null,
): StoreKitTransactionPage {
if (limit !in 1..MAX_PAGE_SIZE) {
throw InvalidStoreKitRequest("limit must be between 1 and 100")
}
val decodedCursor = cursor?.let(StoreKitTransactionCursorCodec::decode)
val results = transactions.inTransaction { unit ->
unit.storeKit.listCreditedTransactions(userId, limit + 1, decodedCursor)
}
val items = results.take(limit)
return StoreKitTransactionPage(
items = items,
nextCursor = if (results.size > limit) {
items.lastOrNull()?.let {
StoreKitTransactionCursorCodec.encode(
StoreKitTransactionCursor(it.purchasedAt, it.transactionId)
)
}
} else {
null
},
)
}
suspend fun submit(
userId: UUID,
signedTransaction: String,
@@ -155,6 +186,40 @@ class StoreKitService(
private companion object {
const val MIN_SIGNED_TRANSACTION_LENGTH = 100
const val MAX_SIGNED_TRANSACTION_LENGTH = 32_768
const val DEFAULT_PAGE_SIZE = 50
const val MAX_PAGE_SIZE = 100
val MAX_CLOCK_SKEW: Duration = Duration.ofMinutes(5)
}
}
internal object StoreKitTransactionCursorCodec {
private const val MAX_CURSOR_LENGTH = 256
private const val INVALID_CURSOR_MESSAGE = "cursor is invalid"
private val TRANSACTION_ID = Regex("[0-9]{1,64}")
fun encode(cursor: StoreKitTransactionCursor): String {
val value = "${cursor.purchasedAt}|${cursor.transactionId}"
return Base64.getUrlEncoder().withoutPadding()
.encodeToString(value.toByteArray(StandardCharsets.UTF_8))
}
fun decode(value: String): StoreKitTransactionCursor {
if (value.length !in 1..MAX_CURSOR_LENGTH || value != value.trim()) {
throw InvalidStoreKitRequest(INVALID_CURSOR_MESSAGE)
}
return try {
val decoded = String(
Base64.getUrlDecoder().decode(value),
StandardCharsets.UTF_8,
)
val parts = decoded.split('|')
require(parts.size == 2 && TRANSACTION_ID.matches(parts[1]))
StoreKitTransactionCursor(
purchasedAt = Instant.parse(parts[0]),
transactionId = parts[1],
)
} catch (_: IllegalArgumentException) {
throw InvalidStoreKitRequest(INVALID_CURSOR_MESSAGE)
}
}
}