Skip to content

Commit

Permalink
feat(vault): export transactions as csv
Browse files Browse the repository at this point in the history
  • Loading branch information
vihangpatil committed Apr 29, 2024
1 parent bc8d705 commit dc5898f
Show file tree
Hide file tree
Showing 10 changed files with 480 additions and 77 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/deploy-endpoints.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ on:
env:
GCP_PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }}
SERVICE_ID: api.k33.com
ESP_FULL_VERSION: 2.47.0
ESP_FULL_VERSION: 2.48.0
jobs:
deploy-endpoints:
runs-on: ubuntu-latest
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.k33.platform.app.vault

import com.k33.platform.app.vault.VaultService.getTransactions
import com.k33.platform.app.vault.VaultService.getVaultAddresses
import com.k33.platform.app.vault.VaultService.getVaultAppSettings
import com.k33.platform.app.vault.VaultService.getVaultAssets
Expand All @@ -8,22 +9,33 @@ import com.k33.platform.app.vault.VaultService.updateVaultAppSettings
import com.k33.platform.identity.auth.gcp.UserInfo
import com.k33.platform.user.UserId
import com.k33.platform.utils.logging.logWithMDC
import io.ktor.http.ContentDisposition
import io.ktor.http.ContentType
import io.ktor.http.HeaderValueParam
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.Parameters
import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.call
import io.ktor.server.auth.authenticate
import io.ktor.server.auth.principal
import io.ktor.server.plugins.BadRequestException
import io.ktor.server.request.receive
import io.ktor.server.response.header
import io.ktor.server.response.respond
import io.ktor.server.response.respondBytes
import io.ktor.server.routing.get
import io.ktor.server.routing.put
import io.ktor.server.routing.route
import io.ktor.server.routing.routing
import io.ktor.utils.io.core.toByteArray
import kotlinx.serialization.Serializable
import java.time.Instant
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeParseException

fun Application.module() {
Expand All @@ -48,6 +60,15 @@ fun Application.module() {
}
}

get("transactions") {
val userId = UserId(call.principal<UserInfo>()!!.userId)
logWithMDC("userId" to userId.value) {
val (afterDate, beforeDate, zoneId) = call.request.queryParameters.getDateRange()
val transactions = userId.getTransactions(afterDate to beforeDate, zoneId)
call.respondTransactionsAsCsv(transactions)
}
}

route("settings") {
get {
val userId = UserId(call.principal<UserInfo>()!!.userId)
Expand Down Expand Up @@ -76,6 +97,17 @@ fun Application.module() {
}
}
}

get("/apps/vault/admin/transactions") {
val vaultAccountId = call.request.queryParameters["vaultAccountId"]
?: throw BadRequestException("Missing query parameter: vaultAccountId")
logWithMDC("vaultAccountId" to vaultAccountId) {
val (afterDate, beforeDate, zoneId) = call.request.queryParameters.getDateRange()
val transactions = getTransactions(vaultAccountId, afterDate to beforeDate, zoneId)
call.respondTransactionsAsCsv(transactions)
}
}

put("/admin/jobs/generate-vault-accounts-balance-reports/{date?}") {
val mode = call.request.queryParameters.getEnum("mode") ?: Mode.FETCH_AND_STORE
val date = call.parameters.getLocalDate("date")
Expand All @@ -89,26 +121,64 @@ fun Application.module() {
}
}

private inline fun <reified E: Enum<E>> Parameters.getEnum(name: String): E? {
return get(name)
?.let {
try {
enumValueOf<E>(it)
} catch (e: IllegalArgumentException) {
throw BadRequestException("Invalid enum value: $it of $name")
}
private inline fun <reified E : Enum<E>> Parameters.getEnum(
name: String
): E? = get(name)
?.let {
try {
enumValueOf<E>(it)
} catch (e: IllegalArgumentException) {
throw BadRequestException("Invalid enum value: $it of $name")
}
}

private fun Parameters.getLocalDate(
name: String
): LocalDate? = get(name)
?.let {
try {
LocalDate.parse(it)
} catch (e: DateTimeParseException) {
throw BadRequestException("Invalid local date value: $it of $name")
}
}

private fun Parameters.getDateRange(): Triple<Instant, Instant, ZoneId> {
val zoneId = this["zoneId"]?.let(ZoneId::of) ?: ZoneId.of("Europe/Oslo")
val afterDate = ZonedDateTime.of(getLocalDate("afterDate"), LocalTime.MIN, zoneId).toInstant()
val beforeDate = ZonedDateTime.of(getLocalDate("beforeDate"), LocalTime.MAX, zoneId).toInstant()
return Triple(afterDate, beforeDate, zoneId)
}

private fun Parameters.getLocalDate(name: String): LocalDate? {
return get(name)
?.let {
try {
LocalDate.parse(it)
} catch (e: DateTimeParseException) {
throw BadRequestException("Invalid local date value: $it of $name")
}
private suspend fun ApplicationCall.respondTransactionsAsCsv(
transactions: List<Transaction>,
) {
val csvBytes = toCsv(transactions).toByteArray(Charsets.UTF_8)
this.response.header(
HttpHeaders.ContentDisposition,
ContentDisposition.Attachment.withParameters(
listOf(
HeaderValueParam(ContentDisposition.Parameters.FileName, "transactions.csv"),
HeaderValueParam(ContentDisposition.Parameters.Size, csvBytes.size.toString()),
)
).toString()
)
this.respondBytes(
bytes = csvBytes,
contentType = ContentType.Text.CSV,
status = HttpStatusCode.OK,
)
}

private fun toCsv(
transactions: List<Transaction>
): String = buildString {
appendLine("Id,CreatedAt,Operation,Direction,Asset,Amount,NetAmount,AmountUSD,FeeCurrency,Fee")
transactions.forEach { transaction ->
with(transaction) {
appendLine(""""$id","$createdAt","$operation","$direction","$assetId",$amount,$netAmount,$amountUSD,"$feeCurrency",$fee""")
}
}
}

@Serializable
Expand Down Expand Up @@ -150,8 +220,10 @@ enum class Mode(
) {
// running on time to store a snapshot of balances and generate PDFs
FETCH_AND_STORE(fetch = true, store = true),

// testing to generate PDFs only
FETCH(fetch = true, store = false),

// running later to regenerate PDFs only
LOAD(fetch = false, store = false);

Expand All @@ -161,4 +233,17 @@ enum class Mode(

val useCurrentFxRate: Boolean
get() = store
}
}

data class Transaction(
val id: String,
val createdAt: String,
val operation: String,
val direction: String,
val assetId: String,
val amount: String,
val netAmount: String,
val amountUSD: String,
val feeCurrency: String,
val fee: String,
)
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ import com.k33.platform.app.vault.coingecko.CoinGeckoClient
import com.k33.platform.app.vault.pdf.getBalanceReport
import com.k33.platform.app.vault.stripe.StripeService
import com.k33.platform.filestore.FileStoreService
import com.k33.platform.fireblocks.service.Destination
import com.k33.platform.fireblocks.service.Destinations
import com.k33.platform.fireblocks.service.FireblocksService
import com.k33.platform.fireblocks.service.TxnSrcDest
import com.k33.platform.identity.auth.gcp.FirebaseAuthService
import com.k33.platform.user.UserId
import com.k33.platform.utils.logging.NotifySlack
Expand All @@ -16,7 +19,11 @@ import io.ktor.server.plugins.NotFoundException
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import java.math.BigDecimal
import java.time.Instant
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.ZoneId
import java.util.Locale

object VaultService {
Expand Down Expand Up @@ -144,6 +151,90 @@ object VaultService {
firestoreClient.put(inVaultAppContext(), vaultApp)
}

suspend fun UserId.getTransactions(
dateRange: Pair<Instant, Instant>,
zoneId: ZoneId,
): List<Transaction> {
val vaultApp = firestoreClient.get(inVaultAppContext())
?: throw NotFoundException("Not registered to K33 Vault service")

val vaultAccountId = vaultApp.vaultAccountId
val vaultAccount = FireblocksService.fetchVaultAccountById(
vaultAccountId = vaultAccountId,
)

if (vaultAccount == null) {
logger.warn(
NotifySlack.ALERTS,
"No vault account (id: $vaultAccountId) found in Fireblocks"
)
return emptyList()
}

return getTransactions(
vaultAccountId = vaultAccountId,
dateRange = dateRange,
zoneId = zoneId
)
}

suspend fun getTransactions(
vaultAccountId: String,
dateRange: Pair<Instant, Instant>,
zoneId: ZoneId
): List<Transaction> {
val (after, before) = dateRange
return FireblocksService.fetchTransactions(
vaultAccountId = vaultAccountId,
after = after,
before = before,
).map { transaction ->
val createdAt = LocalDateTime.ofInstant(Instant.ofEpochMilli(transaction.createdAt!!), zoneId)
val fee = transaction.feeInfo?.let {
(it.serviceFee?.toBigDecimalOrNull() ?: BigDecimal.ZERO) +
(it.networkFee?.toBigDecimalOrNull() ?: BigDecimal.ZERO) +
(it.gasPrice?.toBigDecimalOrNull() ?: BigDecimal.ZERO)
} ?: BigDecimal.ZERO

// this will be overwritten by transaction.destinations.* if transaction.destinations != null
var amount = transaction.amountInfo?.amount ?: ""
var amountUSD = transaction.amountInfo?.amountUSD ?: ""

fun TxnSrcDest?.isVaultAccountWith(id: String): Boolean = this?.type == "VAULT_ACCOUNT" && this.id == id

// needed only if transaction source and destination are not the vault account
val destinations by lazy {
transaction.destinations?.find {
it.destination?.type == "VAULT_ACCOUNT" && it.destination?.id == vaultAccountId
}
}

val direction = when {
transaction.source.isVaultAccountWith(id = vaultAccountId) -> "DEBIT"
transaction.destination.isVaultAccountWith(id = vaultAccountId) -> "CREDIT"
destinations != null -> {
amount = destinations?.amount ?: ""
amountUSD = destinations?.amountUSD ?: ""
"CREDIT"
}
else -> ""
}

Transaction(
id = transaction.id ?: "",
createdAt = createdAt.toString(),
operation = transaction.operation ?: "",
direction = direction,
assetId = transaction.assetId ?: "",
amount = amount,
netAmount = transaction.amountInfo?.netAmount ?: "",
amountUSD = amountUSD,
feeCurrency = transaction.feeCurrency ?: "",
fee = fee.toPlainString(),
)
}
}

suspend fun generateVaultAccountBalanceReports(
date: LocalDate,
mode: Mode,
Expand Down
8 changes: 8 additions & 0 deletions libs/clients/k33-backend-client/src/main/httpx/vault.http
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,11 @@

GET https://api.k33.com/apps/vault/assets
Authorization: Bearer {{firebase_id_token}}

### Get vault transactions by admin

GET https://api.k33.com/apps/vault/admin/transactions
?vaultAccountId={{vault_account_id}}
&afterDate=2023-01-01
&beforeDate=2023-12-31
X-API-KEY: {{admin_api_key}}
Original file line number Diff line number Diff line change
Expand Up @@ -401,6 +401,70 @@ paths:
security:
- firebase: [ ]

"/apps/vault/transactions":
get:
description: Get vault transactions
operationId: "getVaultTransactions"
produces:
- "text/csv"
parameters:
- name: "afterDate"
in: query
type: string
format: date
required: true
- name: "beforeDate"
in: query
type: string
format: date
required: true
- name: "zoneId"
in: query
type: string
format: date
required: false
responses:
200:
description: "CSV file list of transactions"
404:
description: "User/account not registered/found"
security:
- firebase: [ ]

"/apps/vault/admin/transactions":
get:
description: Get vault transactions
operationId: "getVaultTransactionsByAdmin"
produces:
- "text/csv"
parameters:
- name: "vaultAccountId"
in: query
type: string
required: true
- name: "afterDate"
in: query
type: string
format: date
required: true
- name: "beforeDate"
in: query
type: string
format: date
required: true
- name: "zoneId"
in: query
type: string
format: date
required: false
responses:
200:
description: "CSV file list of transactions"
404:
description: "User/account not registered/found"
security:
- api_key: [ ]

"/apps/vault/settings":
get:
description: Get vault app setting
Expand Down
Loading

0 comments on commit dc5898f

Please sign in to comment.