feat: parse and store createdAt from time in PaymentRecord

This commit is contained in:
2026-06-03 00:15:13 +02:00
parent 05562f847d
commit 5dd2edce53
6 changed files with 66 additions and 21 deletions
@@ -166,7 +166,9 @@ data class PaymentRecord(
@SerializedName("bolt11") val bolt11: String?, @SerializedName("bolt11") val bolt11: String?,
@SerializedName("pending") val pending: Boolean, @SerializedName("pending") val pending: Boolean,
@SerializedName("preimage") val preimage: String?, @SerializedName("preimage") val preimage: String?,
@SerializedName("extra") val extra: PaymentExtra? @SerializedName("extra") val extra: PaymentExtra?,
// Not from the API — populated only when loaded from Room via toDomain()
val createdAt: Long? = null
) { ) {
val amountSat: Long get() = kotlin.math.abs(amountMsat) / 1000L val amountSat: Long get() = kotlin.math.abs(amountMsat) / 1000L
val feeSat: Long get() = kotlin.math.abs(feeMsat) / 1000L val feeSat: Long get() = kotlin.math.abs(feeMsat) / 1000L
@@ -12,9 +12,9 @@ import androidx.sqlite.db.SupportSQLiteDatabase
entities = [ entities = [
PaymentRecordEntity::class, PaymentRecordEntity::class,
LnurlCacheEntity::class, LnurlCacheEntity::class,
NodeAliasCacheEntity::class, // ← added NodeAliasCacheEntity::class,
], ],
version = 3, // ← bumped version = 4, // ← bumped
exportSchema = false exportSchema = false
) )
@TypeConverters(PaymentConverters::class) @TypeConverters(PaymentConverters::class)
@@ -22,7 +22,7 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun paymentDao(): PaymentDao abstract fun paymentDao(): PaymentDao
abstract fun lnurlCacheDao(): LnurlCacheDao abstract fun lnurlCacheDao(): LnurlCacheDao
abstract fun nodeAliasCacheDao(): NodeAliasCacheDao // ← added abstract fun nodeAliasCacheDao(): NodeAliasCacheDao
companion object { companion object {
@Volatile private var INSTANCE: AppDatabase? = null @Volatile private var INSTANCE: AppDatabase? = null
@@ -42,7 +42,7 @@ abstract class AppDatabase : RoomDatabase() {
} }
} }
private val MIGRATION_2_3 = object : Migration(2, 3) { // ← added private val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) { override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL( db.execSQL(
""" """
@@ -57,6 +57,19 @@ abstract class AppDatabase : RoomDatabase() {
} }
} }
private val MIGRATION_3_4 = object : Migration(3, 4) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE payment_records ADD COLUMN createdAt INTEGER")
db.execSQL(
"""
UPDATE payment_records
SET createdAt = CAST(time AS INTEGER)
WHERE CAST(time AS INTEGER) > 1000000000
""".trimIndent()
)
}
}
fun getInstance(context: Context): AppDatabase = fun getInstance(context: Context): AppDatabase =
INSTANCE ?: synchronized(this) { INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder( INSTANCE ?: Room.databaseBuilder(
@@ -64,7 +77,7 @@ abstract class AppDatabase : RoomDatabase() {
AppDatabase::class.java, AppDatabase::class.java,
"gudari_wallet.db" "gudari_wallet.db"
) )
.addMigrations(MIGRATION_1_2, MIGRATION_2_3) // ← added .addMigrations(MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4) // ← added
.build() .build()
.also { INSTANCE = it } .also { INSTANCE = it }
} }
@@ -14,6 +14,7 @@ data class PaymentRecordEntity(
val feeMsat: Long, val feeMsat: Long,
val memo: String?, val memo: String?,
val time: String, val time: String,
val createdAt: Long? = null,
val status: String, val status: String,
val bolt11: String?, val bolt11: String?,
val pending: Boolean, val pending: Boolean,
@@ -1,8 +1,27 @@
package com.bitcointxoko.gudariwallet.data.db package com.bitcointxoko.gudariwallet.data.db
import android.util.Log
import com.bitcointxoko.gudariwallet.api.PaymentRecord import com.bitcointxoko.gudariwallet.api.PaymentRecord
import com.bitcointxoko.gudariwallet.data.db.PaymentRecordEntity import com.bitcointxoko.gudariwallet.data.db.PaymentRecordEntity
private const val TAG = "PaymentMappers"
// ── Parse helper — runs once at write time, never again ──────────────────────
private fun parseCreatedAt(time: String): Long? {
val result = time.toLongOrNull()
?: runCatching {
java.time.OffsetDateTime.parse(time).toEpochSecond()
}.getOrNull()
if (result == null) {
Log.w(TAG, "parseCreatedAt: could not parse time string \"$time\" — createdAt will be null")
}
return result
}
// ── PaymentRecord → PaymentRecordEntity ──────────────────────────────────────
fun PaymentRecord.toEntity(savedAt: Long): PaymentRecordEntity = fun PaymentRecord.toEntity(savedAt: Long): PaymentRecordEntity =
PaymentRecordEntity( PaymentRecordEntity(
checkingId = checkingId, checkingId = checkingId,
@@ -11,6 +30,7 @@ fun PaymentRecord.toEntity(savedAt: Long): PaymentRecordEntity =
feeMsat = feeMsat, feeMsat = feeMsat,
memo = memo, memo = memo,
time = time, time = time,
createdAt = parseCreatedAt(time), // ← NEW
status = status, status = status,
bolt11 = bolt11, bolt11 = bolt11,
pending = pending, pending = pending,
@@ -19,6 +39,8 @@ fun PaymentRecord.toEntity(savedAt: Long): PaymentRecordEntity =
savedAt = savedAt savedAt = savedAt
) )
// ── PaymentRecordEntity → PaymentRecord ──────────────────────────────────────
fun PaymentRecordEntity.toDomain(): PaymentRecord = fun PaymentRecordEntity.toDomain(): PaymentRecord =
PaymentRecord( PaymentRecord(
checkingId = checkingId, checkingId = checkingId,
@@ -27,6 +49,7 @@ fun PaymentRecordEntity.toDomain(): PaymentRecord =
feeMsat = feeMsat, feeMsat = feeMsat,
memo = memo, memo = memo,
time = time, time = time,
createdAt = createdAt, // ← NEW
status = status, status = status,
bolt11 = bolt11, bolt11 = bolt11,
pending = pending, pending = pending,
@@ -2,6 +2,7 @@ package com.bitcointxoko.gudariwallet.ui
import android.content.ClipData import android.content.ClipData
import android.content.Intent import android.content.Intent
import android.util.Log
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -493,8 +494,11 @@ private fun PaymentRow(
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Spacer(Modifier.height(2.dp)) Spacer(Modifier.height(2.dp))
val formattedTime = remember(payment.createdAt ?: payment.time) {
formatTimestamp(payment.createdAt, payment.time)
}
Text( Text(
text = formatTimestamp(payment.time), text = formattedTime,
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant color = MaterialTheme.colorScheme.onSurfaceVariant
) )
@@ -702,7 +706,7 @@ private fun PaymentDetailContent(
// ── Basic info ─────────────────────────────────────────────────────── // ── Basic info ───────────────────────────────────────────────────────
item { item {
DetailSection(title = "Details") { DetailSection(title = "Details") {
DetailRow("Date", formatTimestamp(payment.time)) DetailRow("Date", formatTimestamp(payment.createdAt, payment.time))
DetailRow("Memo", payment.memo?.takeIf { it.isNotBlank() } ?: "") DetailRow("Memo", payment.memo?.takeIf { it.isNotBlank() } ?: "")
if (payment.extra?.tag != null) { if (payment.extra?.tag != null) {
DetailRow("Type", payment.extra.tag) DetailRow("Type", payment.extra.tag)
@@ -897,21 +901,22 @@ private fun StatusChip(status: String) {
// ── Helpers ────────────────────────────────────────────────────────────────── // ── Helpers ──────────────────────────────────────────────────────────────────
private fun formatTimestamp(time: String): String { private fun formatTimestamp(createdAt: Long?, rawTime: String): String {
return runCatching { Log.d("TimestampDebug", "createdAt=$createdAt rawTime=$rawTime")
val epochSeconds = time.toLong() val formatter = java.time.format.DateTimeFormatter
java.time.format.DateTimeFormatter
.ofPattern("dd MMM yyyy, HH:mm") .ofPattern("dd MMM yyyy, HH:mm")
.withZone(java.time.ZoneId.systemDefault()) .withZone(java.time.ZoneId.systemDefault())
.format(java.time.Instant.ofEpochSecond(epochSeconds)) if (createdAt != null && createdAt > 1_000_000_000L) { // sanity check
}.recoverCatching { return formatter.format(java.time.Instant.ofEpochSecond(createdAt))
val odt = java.time.OffsetDateTime.parse(time)
java.time.format.DateTimeFormatter
.ofPattern("dd MMM yyyy, HH:mm")
.withZone(java.time.ZoneId.systemDefault())
.format(odt)
}.getOrDefault(time)
} }
// Fallback: only reached for payments not yet written through Room
return runCatching {
formatter.format(java.time.Instant.ofEpochSecond(rawTime.toLong()))
}.recoverCatching {
formatter.format(java.time.OffsetDateTime.parse(rawTime))
}.getOrDefault(rawTime)
}
@Composable @Composable
private fun AmountWithFiatColumn( private fun AmountWithFiatColumn(
@@ -194,7 +194,8 @@ class HistoryViewModel(
if (toMerge.isNotEmpty()) { if (toMerge.isNotEmpty()) {
paymentCache.mergePayments(toMerge) paymentCache.mergePayments(toMerge)
totalNew += toMerge.size totalNew += toMerge.size
Log.d(TAG, "PAYMENTS [SYNC PAGE ] page $pagesChecked${toMerge.size} new payments merged") val withTimestamp = toMerge.count { it.time.toLongOrNull() != null }
Log.d(TAG, "PAYMENTS [SYNC PAGE ] page $pagesChecked${toMerge.size} new payments merged ($withTimestamp with numeric timestamp)")
} }
val reachedOverlap = overlapIndex != -1 val reachedOverlap = overlapIndex != -1