feat: salvar data original da transação e data de vencimento no cartão

Ao importar fatura de cartão com PaymentDate configurado, a data efetiva
da transação passa a ser o vencimento e a data de compra é preservada em
original_date. Migration 013 adiciona a coluna original_date em transactions.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
2026-05-27 16:25:52 -03:00
co-authored by Claude Sonnet 4.6
parent 9ba3a3678b
commit 2b0aa352d5
7 changed files with 40 additions and 15 deletions
+4 -1
View File
@@ -44,6 +44,9 @@ var m011 string
//go:embed sql/012_category_is_tax.sql //go:embed sql/012_category_is_tax.sql
var m012 string var m012 string
//go:embed sql/013_transaction_original_date.sql
var m013 string
func Run(ctx context.Context, pool *pgxpool.Pool) error { func Run(ctx context.Context, pool *pgxpool.Pool) error {
// Bootstrap: ensure schema_migrations table exists before checking versions. // Bootstrap: ensure schema_migrations table exists before checking versions.
if _, err := pool.Exec(ctx, ` if _, err := pool.Exec(ctx, `
@@ -55,7 +58,7 @@ func Run(ctx context.Context, pool *pgxpool.Pool) error {
return fmt.Errorf("bootstrap schema_migrations: %w", err) return fmt.Errorf("bootstrap schema_migrations: %w", err)
} }
migrations := []string{m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012} migrations := []string{m001, m002, m003, m004, m005, m006, m007, m008, m009, m010, m011, m012, m013}
for i, sql := range migrations { for i, sql := range migrations {
version := i + 1 version := i + 1
var applied bool var applied bool
@@ -0,0 +1 @@
ALTER TABLE transactions ADD COLUMN IF NOT EXISTS original_date DATE;
+9 -7
View File
@@ -17,13 +17,14 @@ type Transaction struct {
// ImportRow is a parsed-but-not-yet-saved transaction from a file. // ImportRow is a parsed-but-not-yet-saved transaction from a file.
type ImportRow struct { type ImportRow struct {
Date string `json:"date"` Date string `json:"date"` // effective date (payment date for credit cards)
Amount float64 `json:"amount"` OriginalDate string `json:"original_date"` // purchase date from the CSV (empty for non-credit imports)
Description string `json:"description"` Amount float64 `json:"amount"`
Type string `json:"type"` Description string `json:"description"`
CategoryID *int `json:"category_id,omitempty"` Type string `json:"type"`
ExternalID string `json:"external_id,omitempty"` // FITID from OFX CategoryID *int `json:"category_id,omitempty"`
IsDuplicate bool `json:"is_duplicate"` ExternalID string `json:"external_id,omitempty"` // FITID from OFX
IsDuplicate bool `json:"is_duplicate"`
} }
// ImportResult is the response after confirming an import. // ImportResult is the response after confirming an import.
@@ -44,4 +45,5 @@ type CSVMapping struct {
FieldSeparator string `json:"field_separator"` // "," or ";" FieldSeparator string `json:"field_separator"` // "," or ";"
SkipNegative bool `json:"skip_negative"` // skip rows with negative amount (e.g. credit card bill payments) SkipNegative bool `json:"skip_negative"` // skip rows with negative amount (e.g. credit card bill payments)
AllExpenses bool `json:"all_expenses"` // treat all rows as expense (credit card statements) AllExpenses bool `json:"all_expenses"` // treat all rows as expense (credit card statements)
PaymentDate string `json:"payment_date"` // YYYY-MM-DD; when set, overrides date for all rows (credit card due date)
} }
+13 -4
View File
@@ -96,11 +96,20 @@ func ParseCSV(r io.Reader, m model.CSVMapping) ([]model.ImportRow, []string) {
txType = "income" txType = "income"
} }
purchaseDate := t.Format("2006-01-02")
effectiveDate := purchaseDate
originalDate := ""
if m.PaymentDate != "" {
effectiveDate = m.PaymentDate
originalDate = purchaseDate
}
rows = append(rows, model.ImportRow{ rows = append(rows, model.ImportRow{
Date: t.Format("2006-01-02"), Date: effectiveDate,
Amount: math.Abs(amt), OriginalDate: originalDate,
Description: desc, Amount: math.Abs(amt),
Type: txType, Description: desc,
Type: txType,
}) })
} }
return rows, errs return rows, errs
+7 -3
View File
@@ -62,10 +62,14 @@ func (r *transactionRepo) BulkInsert(ctx context.Context, rows []model.ImportRow
if row.ExternalID != "" { if row.ExternalID != "" {
extID = &row.ExternalID extID = &row.ExternalID
} }
var origDate *string
if row.OriginalDate != "" {
origDate = &row.OriginalDate
}
_, err := tx.Exec(ctx, ` _, err := tx.Exec(ctx, `
INSERT INTO transactions (date, amount, description, type, source, external_id, category_id) INSERT INTO transactions (date, original_date, amount, description, type, source, external_id, category_id)
VALUES ($1, $2, $3, $4, 'import', $5, $6)`, VALUES ($1, $2, $3, $4, $5, 'import', $6, $7)`,
row.Date, row.Amount, row.Description, row.Type, extID, row.CategoryID) row.Date, origDate, row.Amount, row.Description, row.Type, extID, row.CategoryID)
if err != nil { if err != nil {
return count, err return count, err
} }
+1
View File
@@ -27,6 +27,7 @@ export interface CSVMapping {
field_separator: string field_separator: string
skip_negative: boolean skip_negative: boolean
all_expenses: boolean all_expenses: boolean
payment_date: string
} }
export const useImportStore = defineStore('import', () => { export const useImportStore = defineStore('import', () => {
+5
View File
@@ -22,6 +22,7 @@ const csvMapping = ref<CSVMapping>({
field_separator: ';', field_separator: ';',
skip_negative: false, skip_negative: false,
all_expenses: false, all_expenses: false,
payment_date: '',
}) })
// Quando "Fatura de cartão" é marcado, configura automaticamente as colunas corretas // Quando "Fatura de cartão" é marcado, configura automaticamente as colunas corretas
@@ -160,6 +161,10 @@ function fmt(amount: number) {
<input type="checkbox" v-model="csvMapping.skip_negative" /> <input type="checkbox" v-model="csvMapping.skip_negative" />
Ignorar valores negativos Ignorar valores negativos
</label> </label>
<label v-if="csvMapping.all_expenses" class="fc-label">
Data de pagamento (vencimento)
<input type="date" v-model="csvMapping.payment_date" class="fc-input" />
</label>
</div> </div>
</NeonPanel> </NeonPanel>