Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 26 additions & 9 deletions crates/core/src/activities/activities_service.rs
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,21 @@ impl ActivityService {
.ok()
}

/// Build a symbol-based asset identifier for idempotency key computation.
/// Uses raw symbol text + exchange MIC instead of resolved UUID so that
/// keys are stable across imports and match between the review step
/// (`build_import_idempotency_key`) and the import step.
fn symbol_based_asset_id(
symbol_code: Option<&str>,
exchange_mic: Option<&str>,
) -> Option<String> {
let symbol = symbol_code.map(|s| s.trim()).filter(|s| !s.is_empty())?;
match exchange_mic {
Some(mic) => Some(format!("{}@{}", symbol, mic)),
None => Some(symbol.to_string()),
}
}

fn build_import_idempotency_key(
activity: &ActivityImport,
default_account_id: &str,
Expand All @@ -317,14 +332,10 @@ impl ActivityService {
} else {
activity.currency.as_str()
};
let symbol = activity.symbol.trim();
let asset_id = if symbol.is_empty() {
None
} else if let Some(exchange_mic) = activity.exchange_mic.as_deref() {
Some(format!("{}@{}", symbol, exchange_mic))
} else {
Some(symbol.to_string())
};
let asset_id = Self::symbol_based_asset_id(
Some(activity.symbol.as_str()),
activity.exchange_mic.as_deref(),
);

Some(compute_idempotency_key(
account_id,
Expand Down Expand Up @@ -2619,11 +2630,17 @@ impl ActivityServiceTrait for ActivityService {
.ok();

if let Some(date) = date {
// Build asset_id from symbol+MIC (not UUID) to match
// build_import_idempotency_key used in the review step.
let asset_id_for_key = Self::symbol_based_asset_id(
activity.get_symbol_code(),
activity.get_exchange_mic(),
);
Comment on lines +2635 to +2638
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Match legacy idempotency keys during CSV dedupe

This change computes import keys from symbol@MIC only, but previously imported activities in existing databases were keyed with the old UUID-based asset_id path. On the first re-import after this upgrade, check_existing_duplicates and the unique index compare against the new hash and will miss those legacy rows, allowing the same historical activity to be inserted again instead of being flagged/skipped. Keep backward compatibility by checking both key formats (or backfilling old rows) before switching to symbol-only keys.

Useful? React with 👍 / 👎.

let key = compute_idempotency_key(
&activity.account_id,
&activity.activity_type,
&date,
activity.get_symbol_id(),
asset_id_for_key.as_deref(),
activity.quantity,
activity.unit_price,
activity.amount,
Expand Down
27 changes: 22 additions & 5 deletions crates/storage-sqlite/src/activities/repository.rs
Original file line number Diff line number Diff line change
Expand Up @@ -661,12 +661,29 @@ impl ActivityRepositoryTrait for ActivityRepository {

self.writer
.exec_tx(move |tx| -> Result<usize> {
let num_inserted = diesel::insert_into(activities::table)
.values(&activities_db_owned)
.execute(tx.conn())
.map_err(StorageError::from)?;
let mut num_inserted = 0usize;
for activity_db in &activities_db_owned {
tx.insert(activity_db)?;
match diesel::insert_into(activities::table)
.values(activity_db)
.execute(tx.conn())
{
Ok(count) => {
if count > 0 {
tx.insert(activity_db)?;
num_inserted += count;
}
}
Err(diesel::result::Error::DatabaseError(
diesel::result::DatabaseErrorKind::UniqueViolation,
_,
)) => {
log::debug!(
"Skipping duplicate activity {} (idempotency_key constraint)",
activity_db.id
);
}
Err(e) => return Err(StorageError::from(e).into()),
}
}
Ok(num_inserted)
})
Expand Down
Loading