Enhance ledger operations and referral lifecycle
CI / verify (push) Has been cancelled
CI / publish (push) Has been cancelled

Add traceable ledger filtering and permanent referral codes so operators can investigate credit activity without weakening immutable accounting guarantees.
This commit is contained in:
Rocky
2026-08-20 22:14:22 +08:00
parent 74c3fcd45f
commit b5212dcdc2
38 changed files with 3080 additions and 620 deletions
@@ -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
@@ -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(),
)
@@ -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)
}
@@ -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()
@@ -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),
)
}
@@ -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