Coverage Summary for Class: TransactionCsvCodec (com.georgeci.moneysurfer.domain.csv)

Class Method, % Branch, % Line, % Instruction, %
TransactionCsvCodec 100% (4/4) 100% (18/18) 100% (29/29) 100% (193/193)
TransactionCsvCodec$FieldReader 100% (11/11) 100% (26/26) 100% (42/42) 100% (342/342)
TransactionCsvCodec$FieldRejectedException 100% (1/1) 100% (1/1) 100% (7/7)
Total 100% (16/16) 100% (44/44) 100% (72/72) 100% (542/542)


 package com.georgeci.moneysurfer.domain.csv
 
 import com.georgeci.moneysurfer.domain.model.Transaction
 import com.georgeci.moneysurfer.domain.model.TransactionTags
 import com.georgeci.moneysurfer.domain.primitives.AccountId
 import com.georgeci.moneysurfer.domain.primitives.CategoryId
 import com.georgeci.moneysurfer.domain.primitives.CurrencyCode
 import com.georgeci.moneysurfer.domain.primitives.Money
 import com.georgeci.moneysurfer.domain.primitives.RecurringRuleId
 import com.georgeci.moneysurfer.domain.primitives.SplitId
 import com.georgeci.moneysurfer.domain.primitives.TransactionId
 import com.georgeci.moneysurfer.domain.primitives.TransactionStatus
 import com.georgeci.moneysurfer.domain.primitives.TransactionType
 import com.georgeci.moneysurfer.domain.primitives.TransferId
 import com.georgeci.moneysurfer.domain.primitives.WorkspaceId
 import kotlinx.datetime.LocalDate
 import kotlin.time.Instant
 
 /**
  * Every column the transactions CSV has ever had. Declaration order is the
  * order [TransactionCsvFormat.Latest] writes them in, so ordinal == field index
  * in a record this build *writes*. A record being *read* may come from an older
  * layout that holds fewer columns — index those through
  * [TransactionCsvFormat.indexOf] instead.
  */
 enum class TransactionCsvColumn(val header: String) {
     Id("id"),
     WorkspaceId("workspace_id"),
     AccountId("account_id"),
     Type("type"),
     Status("status"),
     AmountMinor("amount_minor"),
     Currency("currency"),
     CategoryId("category_id"),
     Note("note"),
     OperationAt("operation_at"),
     OperationDate("operation_date"),
     CreatedAt("created_at"),
     UpdatedAt("updated_at"),
     TransferId("transfer_id"),
     Merchant("merchant"),
     Tags("tags"),
     RecurringRuleId("recurring_rule_id"),
     SplitId("split_id"),
 }
 
 sealed interface TransactionCsvDecodeResult {
     data class Decoded(val transaction: Transaction) : TransactionCsvDecodeResult
     data class Rejected(val issue: CsvRowIssue) : TransactionCsvDecodeResult
 }
 
 /**
  * Maps a [Transaction] to/from one CSV record. Amounts are minor units,
  * instants are ISO-8601 strings, `operation_date` is an ISO local date, and
  * nullable ids serialise as empty fields. Purely structural — reference
  * validation (does the account exist?) happens in the import use case.
  *
  * Writing is always [TransactionCsvFormat.Latest]; reading accepts any layout
  * this codec has published, which the caller resolves from the file's header
  * row via [TransactionCsvFormat.forHeader] and passes to [decode].
  */
 object TransactionCsvCodec {
 
     /** Header row of an export — the layout marker readers match on. */
     val header: List<String> = TransactionCsvFormat.Latest.header
 
     fun encode(transaction: Transaction): List<String> = with(transaction) {
         listOf(
             id.value,
             workspaceId.value,
             accountId.value,
             type.name,
             status.name,
             money.minor.toString(),
             currencyCode.value,
             categoryId?.value.orEmpty(),
             // Free text: neutralise CSV formula injection before it reaches a
             // spreadsheet. Reversed by unguardFormula on decode.
             Csv.guardFormula(note),
             operationAt.toString(),
             operationDate.toString(),
             createdAt.toString(),
             updatedAt.toString(),
             transferId?.value.orEmpty(),
             // Free text like the note, and guarded the same way.
             Csv.guardFormula(merchant),
             // Each tag is guarded on its own: any of them can be the one the
             // spreadsheet would evaluate once a user re-orders the cell by hand.
             Csv.encodeCellList(tags.map(Csv::guardFormula)),
             recurringRuleId?.value.orEmpty(),
             splitId?.value.orEmpty(),
         )
     }
 
     /**
      * Decodes one record read from a [format] file. Rejects anything whose field
      * count is not exactly that format's — the decoder's only integrity check,
      * and it holds per format, so a row missing its tail is still rejected
      * rather than back-filled with defaults.
      */
     fun decode(
         fields: List<String>,
         format: TransactionCsvFormat = TransactionCsvFormat.Latest,
     ): TransactionCsvDecodeResult {
         if (fields.size != format.columns.size) {
             return TransactionCsvDecodeResult.Rejected(
                 CsvRowIssue.ColumnCountMismatch(
                     expected = format.columns.size,
                     actual = fields.size,
                 ),
             )
         }
         return FieldReader(fields, format).decodeTransaction()
     }
 
     private class FieldReader(
         private val fields: List<String>,
         private val format: TransactionCsvFormat,
     ) {
 
         fun decodeTransaction(): TransactionCsvDecodeResult = try {
             TransactionCsvDecodeResult.Decoded(
                 Transaction(
                     id = TransactionId(required(TransactionCsvColumn.Id)),
                     workspaceId = WorkspaceId(required(TransactionCsvColumn.WorkspaceId)),
                     accountId = AccountId(required(TransactionCsvColumn.AccountId)),
                     money = Money(amountMinor(TransactionCsvColumn.AmountMinor)),
                     currencyCode = CurrencyCode(required(TransactionCsvColumn.Currency)),
                     categoryId = optional(TransactionCsvColumn.CategoryId)?.let(::CategoryId),
                     note = Csv.unguardFormula(raw(TransactionCsvColumn.Note)),
                     merchant = Csv.unguardFormula(raw(TransactionCsvColumn.Merchant)),
                     tags = tags(TransactionCsvColumn.Tags),
                     operationAt = instant(TransactionCsvColumn.OperationAt),
                     operationDate = localDate(TransactionCsvColumn.OperationDate),
                     type = enum<TransactionType>(TransactionCsvColumn.Type),
                     status = enum<TransactionStatus>(TransactionCsvColumn.Status),
                     createdAt = instant(TransactionCsvColumn.CreatedAt),
                     updatedAt = instant(TransactionCsvColumn.UpdatedAt),
                     transferId = optional(TransactionCsvColumn.TransferId)?.let(::TransferId),
                     recurringRuleId = optional(TransactionCsvColumn.RecurringRuleId)
                         ?.let(::RecurringRuleId),
                     splitId = optional(TransactionCsvColumn.SplitId)?.let(::SplitId),
                 ),
             )
         } catch (rejected: FieldRejectedException) {
             TransactionCsvDecodeResult.Rejected(CsvRowIssue.InvalidValue(rejected.column.header))
         }
 
         /**
          * The record's value for [column], or `""` when [format] predates the
          * column. Empty is already the "not recorded" encoding of every column
          * added after [TransactionCsvFormat.V1] (blank merchant, no tags, null
          * id), and [required] still rejects a blank field, so an older layout
          * can never quietly satisfy a mandatory column.
          */
         private fun raw(column: TransactionCsvColumn): String {
             val index = format.indexOf(column) ?: return ""
             return fields[index]
         }
 
         private fun required(column: TransactionCsvColumn): String =
             raw(column).ifBlank { throw FieldRejectedException(column) }
 
         private fun optional(column: TransactionCsvColumn): String? =
             raw(column).ifBlank { null }
 
         /**
          * Unpacks the tag cell and re-normalises it. The cell can come from a
          * hand-edited or hostile file, and [TransactionTags.normalize] is what
          * stops a comma — the separator of the Room column these land in — or a
          * 500-tag list from reaching storage. Tags this codec wrote are already
          * normalised and normalisation is idempotent, so the round-trip is
          * unaffected.
          */
         private fun tags(column: TransactionCsvColumn): List<String> =
             TransactionTags.normalize(
                 Csv.decodeCellList(raw(column)).map(Csv::unguardFormula),
             )
 
         private fun long(column: TransactionCsvColumn): Long =
             raw(column).toLongOrNull() ?: throw FieldRejectedException(column)
 
         /**
          * A monetary minor amount bounded to the domain cap. Rejects crafted
          * out-of-range values (e.g. [Long.MIN_VALUE], whose abs() would wrap)
          * before they enter a [Money] and corrupt a cached balance. See #159.
          */
         private fun amountMinor(column: TransactionCsvColumn): Long {
             val value = long(column)
             if (value < -Money.MAX_MINOR || value > Money.MAX_MINOR) {
                 throw FieldRejectedException(column)
             }
             return value
         }
 
         private fun instant(column: TransactionCsvColumn): Instant = try {
             Instant.parse(required(column))
         } catch (_: IllegalArgumentException) {
             throw FieldRejectedException(column)
         }
 
         private fun localDate(column: TransactionCsvColumn): LocalDate = try {
             LocalDate.parse(required(column))
         } catch (_: IllegalArgumentException) {
             throw FieldRejectedException(column)
         }
 
         private inline fun <reified E : Enum<E>> enum(column: TransactionCsvColumn): E =
             enumValues<E>().find { it.name == raw(column) }
                 ?: throw FieldRejectedException(column)
     }
 
     private class FieldRejectedException(val column: TransactionCsvColumn) : RuntimeException()
 }