Add StoreKit history and modernize admin console
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:
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+84
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user