diff --git a/apps/finance/services/api/main/handler.go b/apps/finance/services/api/main/handler.go index cbec3ce..6a1ab98 100644 --- a/apps/finance/services/api/main/handler.go +++ b/apps/finance/services/api/main/handler.go @@ -2,7 +2,9 @@ package main import ( "context" + "crypto/sha256" "embed" + "encoding/hex" "encoding/json" "fmt" "html/template" @@ -792,8 +794,24 @@ func (h *Handler) ImportPreview(w http.ResponseWriter, r *http.Request) { } } + // compute fingerprints and detect duplicates + var fingerprints []string for i := range rows { rows[i].Category = autoCategorize(rows[i].Description, catMap) + rows[i].Fingerprint = txnFingerprint(rows[i].Date, rows[i].Description, rows[i].AmountCents, accountID) + fingerprints = append(fingerprints, rows[i].Fingerprint) + } + existing, _ := h.store.getTransactions(ctx, a.UserID, bson.M{"bank_ref": bson.M{"$in": fingerprints}}) + existingRefs := map[string]bool{} + for _, t := range existing { + existingRefs[t.BankRef] = true + } + duplicateCount := 0 + for i := range rows { + if existingRefs[rows[i].Fingerprint] { + rows[i].Duplicate = true + duplicateCount++ + } } importPreview := &CSVImportPreview{ @@ -815,6 +833,7 @@ func (h *Handler) ImportPreview(w http.ResponseWriter, r *http.Request) { "SelectedFormat": string(format), "SelectedAccount": accountID, "CategoryColors": catColors, + "DuplicateCount": duplicateCount, }) } @@ -861,9 +880,24 @@ func (h *Handler) ImportConfirm(w http.ResponseWriter, r *http.Request) { userCats := r.Form["categories"] + // compute fingerprints and skip duplicates + var fingerprints []string + for _, row := range rows { + fingerprints = append(fingerprints, txnFingerprint(row.Date, row.Description, row.AmountCents, accountID)) + } + existing, _ := h.store.getTransactions(ctx, a.UserID, bson.M{"bank_ref": bson.M{"$in": fingerprints}}) + existingRefs := map[string]bool{} + for _, t := range existing { + existingRefs[t.BankRef] = true + } + now := time.Now() var txns []Transaction for i, row := range rows { + fp := fingerprints[i] + if existingRefs[fp] { + continue + } date, _ := time.Parse("2006-01-02", row.Date) cat := "Others" if i < len(userCats) && userCats[i] != "" { @@ -878,10 +912,16 @@ func (h *Handler) ImportConfirm(w http.ResponseWriter, r *http.Request) { Description: row.Description, AmountCents: row.AmountCents, Category: cat, + BankRef: fp, CreatedAt: now, }) } + if len(txns) == 0 { + http.Redirect(w, r, "/transactions?notice=all_duplicates", http.StatusSeeOther) + return + } + if err := h.store.createTransactions(ctx, txns); err != nil { slog.Error("create transactions", "err", err) http.Error(w, "save error", http.StatusInternalServerError) @@ -891,6 +931,11 @@ func (h *Handler) ImportConfirm(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/transactions", http.StatusSeeOther) } +func txnFingerprint(date, description string, amountCents int64, accountID string) string { + h := sha256.Sum256([]byte(fmt.Sprintf("%s|%s|%d|%s", date, description, amountCents, accountID))) + return hex.EncodeToString(h[:])[:16] +} + func autoCategorize(desc string, catMap map[string]string) string { desc = strings.ToLower(desc) keywords := map[string]string{ diff --git a/apps/finance/services/api/main/models.go b/apps/finance/services/api/main/models.go index 376afc9..d230dda 100644 --- a/apps/finance/services/api/main/models.go +++ b/apps/finance/services/api/main/models.go @@ -96,6 +96,8 @@ type CSVImportRow struct { Description string `json:"description"` AmountCents int64 `json:"amount_cents"` Category string `json:"category"` + Fingerprint string `json:"fingerprint"` + Duplicate bool `json:"duplicate"` } type CSVImportPreview struct { diff --git a/apps/finance/services/api/main/templates/import.html b/apps/finance/services/api/main/templates/import.html index fa55085..ef6a40a 100644 --- a/apps/finance/services/api/main/templates/import.html +++ b/apps/finance/services/api/main/templates/import.html @@ -14,7 +14,12 @@
{{$d.Preview.Total}} rows — review categories before confirming.
++ {{$d.Preview.Total}} rows + {{if $d.DuplicateCount}} + — {{$d.DuplicateCount}} already imported (shown greyed out, will be skipped) + {{end}} +