Skip to content

Counterparty Matching ​

Counterparty matching maps bank statement counterparty text to Syrve/iiko counterparties. It improves allocation candidate ranking but is not required to confirm allocation or queue Syrve posting.

Runtime Flow ​

  1. Bank adapters create normalized BankTransaction records.
  2. During import, BankStatementImportService loads active only (IsActive = true) CounterpartyMatchRule rows for the import scope once.
  3. Rule scope includes the import OrganizationId + LocationId, organization-level rules for the same organization (LocationId = null), and inherited parent-organization global rules (LocationId = null, parent OrganizationId).
  4. Inactive rules are excluded from import matching. Deactivated rules do not auto-match new imports; existing CounterpartyMatch history keeps prior MatchedRuleId.
  5. For each transaction, the backend chooses CounterpartyRawName; if it is empty, it falls back to RawDescription.
  6. The candidate text is normalized and matched against active rule patterns using effective rule order (see Import rule precedence below).
  7. If a MatchSupplier rule matches, the service creates CounterpartyMatch with Status = AutoMatched, ConfidenceScore = 1, selected Syrve/iiko counterparty fields, IsManual = false, and explainability fields (MatchType, Reason, MatchedSignalsJson, MatchedRuleId, MatchedRulePattern, NormalizedBankName). If an ExcludeNonSupplier rule matches, it creates the same explainability link but sets Status/MatchType = Excluded, clears supplier fields, and records ExcludeCategory.
  8. If no active rule matches, the service creates CounterpartyMatch with Status = Unmatched, ConfidenceScore = 0, Reason = "Контрагент не найден", and explainability fields for the unmatched case (no MatchedRuleId / MatchedRulePattern).
  9. Operators review matches on /name-matching.
  10. Manual confirmation updates the existing match and can be used to create a reusable rule for future imports.

The legacy route /matching is kept as a redirect to /name-matching.

Statuses ​

  • Unmatched means no active rule matched the normalized bank counterparty text.
  • AutoMatched means an active rule matched during import.
  • ManualMatched means an operator selected the Syrve/iiko counterparty.
  • Excluded means an operator or active exclusion rule marked the transaction as not a supplier payment (Salary/Tax/BankFee/InternalTransfer/Other). Excluded rows leave Attention/Ready and cannot be allocated. Every new manual exclusion requires an editable pattern, creates/reuses an ExcludeNonSupplier rule, and reapplies it to similar rows.
  • LowConfidence is not a domain enum. It is a query/UI state for AutoMatched rows with confidenceScore < 0.85.

Name Normalization ​

Counterparty matching uses ICounterpartyNameNormalizer from the Application layer.

Base normalization ​

  • trims leading/trailing whitespace;
  • collapses repeated whitespace to one space;
  • uppercases with invariant culture.

The normalizer applies additional safe rules for Indonesian bank counterparty text:

  • PT. / CV. punctuation is normalized to PT / CV with a separating space;
  • glued prefixes such as PTKOLBASA become PT KOLBASA when the remainder looks like a company name (minimum length, not a generic token);
  • bank transfer noise tokens are removed before matching: TRF, PAY, BI, FAST, TRANSFER;
  • PT / CV are kept only when followed by a meaningful company-name token.

Examples:

Raw bank textNormalized candidate
PT. KOLBASAPT KOLBASA
PTKOLBASAPT KOLBASA
BI FAST TRF PT. KOLBASAPT KOLBASA
CV. MAJU JAYACV MAJU JAYA

Generic token blacklist and safety ​

Generic/noise-only text must not become an auto-match candidate.

Configurable options: CounterpartyNameNormalizationOptions (CounterpartyNameNormalization section). Default generic tokens: PT, CV, TRF, PAY, BI, FAST, TRANSFER.

Safety rules:

  • if input is empty/whitespace, normalization returns an empty string;
  • if after noise removal only generic tokens remain (PT, CV, transfer noise), normalization returns an empty string;
  • if the meaningful name (excluding legal prefix) is too short, normalization returns an empty string;
  • import matching skips rules whose Pattern normalizes to an empty string, so generic rules like PT or CV cannot auto-match every Indonesian company;
  • rule creation rejects patterns that normalize to empty (400 with pattern-required error).

Dangerous/generic-only inputs therefore follow the existing safe path: no rule match → Unmatched, ConfidenceScore = 0.

Punctuation stripping beyond PT. / CV. is intentionally limited to avoid unsafe over-matching.

The same normalizer is used when creating rules, checking duplicates, applying rules during import, and normalized preview in the rule modal.

Workbench one-step matching (happy path) ​

Primary matching UX lives in the Accountant Workbench Drawer (WorkbenchMatchPanel):

  1. Drawer header shows file, Syrve counterparty (if known), purpose, and required action — once.
  2. Match panel does not repeat bank counterparty / purpose.
  3. Searchable Syrve counterparty selector.
  4. Optional Remember for future operations — editable auto-match pattern (not a raw remittance dump): default is a cleaned name fragment; operator can shorten it further.
  5. Advanced (collapsed): normalized pattern preview, priority.
  6. Conflict preview runs automatically; blocking conflict prevents rule creation but still allows manual match.
  7. Primary action Match — no completion screen; feedback + refresh; in sequential mode opens the next Attention row.
  8. Not a supplier payment requires category + editable auto-exclusion key. Submit excludes the current row, saves/reuses the rule, and reapplies it to similar rows. Active exclusion rules are visible on the Exclusions tab.

The 4-step wizard on /name-matching remains an advanced/detail route, not the required happy path.

Counterparty Match Wizard (advanced) ​

On /name-matching, operators can still open the Counterparty Match Wizard from a transaction row (Сопоставить, Проверить, or Изменить). The wizard composes existing endpoints — no dedicated wizard API.

Steps:

  1. Transaction review — safe summary: date, amount/currency, direction, counterparty raw name, description, current status/reason/explainability. No raw bank files, full account numbers, or secrets.
  2. Select Syrve/iiko counterparty — search/select from GET /api/syrve/counterparties; submit via POST /api/counterparty-matches (useManualCounterpartyMatch).
  3. Rule decision — after manual match succeeds, operator chooses finish without rule or create reusable rule. Pattern prefilled from counterpartyRawName → bankName → rawDescription; normalized preview shown; conflict preview via POST /api/counterparties/rules/conflict-preview (useCounterpartyRuleConflictPreview) before save; submit via POST /api/counterparties/rules (useCreateCounterpartyRule). Duplicate response (alreadyExists = true) is a successful wizard outcome, not an error.
  4. Completion — success message (manual match saved; rule created, duplicate reused, or rule skipped). TanStack Query invalidates name-matches, counterparty-matches, counterparties, and dashboard without page reload.

Component: frontend/src/pages/matching/CounterpartyMatchWizard.tsx, embedded in MatchingPage.tsx.

Manual Match ​

POST /api/counterparty-matches receives bankTransactionId, syrveCounterpartyId, and syrveCounterpartyName. It updates the existing CounterpartyMatch for the transaction:

  • Status = ManualMatched
  • ConfidenceScore = 1
  • IsManual = true
  • selected Syrve/iiko counterparty fields
  • MatchedByUserId
  • UpdatedAtUtc

The endpoint writes an audit log entry with AuditAction.Match and returns CounterpartyMatchDto.

Rules Created From Manual Matches ​

After manual match in the wizard (step 3), operators can create a rule prefilled with:

  • bank pattern from counterpartyRawName, otherwise bankName, otherwise rawDescription;
  • normalized preview;
  • selected syrveCounterpartyId;
  • selected syrveCounterpartyName;
  • priority, defaulting to 100 in the UI.

Backend endpoint: POST /api/counterparties/rules.

Rule fields:

  • OrganizationId
  • LocationId
  • Pattern
  • NormalizedPattern
  • SyrveCounterpartyId
  • SyrveCounterpartyName
  • IsActive
  • Priority
  • CreatedByUserId
  • timestamps from BaseEntity

The endpoint requires PayAuthorizationPolicies.CounterpartyMatch, requires a BankTransactionId, derives scope from that transaction, and never creates a global rule implicitly.

Duplicate Protection ​

An active rule with the same OrganizationId, LocationId, NormalizedPattern, and SyrveCounterpartyId is treated as a duplicate. Duplicate requests return 200 OK with the existing rule and alreadyExists = true; no second active rule is created.

The database also has an index over OrganizationId + LocationId + NormalizedPattern + SyrveCounterpartyId + IsActive. Application-level duplicate protection remains the source of truth for user-friendly behavior and PostgreSQL nullable LocationId semantics.

Rule conflict preview / Conflict detection ​

Before creating a rule, operators see a conflict preview on wizard step 3 (CounterpartyMatchWizard).

Backend service: CounterpartyRuleConflictDetector (Application layer, pure logic over active rule summaries loaded in the API controller).

Endpoint: POST /api/counterparties/rules/conflict-preview — same auth/permission/validation/normalizer as rule create; does not persist rules.

Conflict types:

TypeSeverityMeaning
ExactDuplicateInfoSame scope + normalized pattern + Syrve counterparty; create returns existing rule (alreadyExists = true).
DuplicateScopeDifferentCounterpartyBlockingSame normalized pattern maps to a different Syrve/iiko counterparty in scope.
OverlappingPatternWarningNormalized patterns overlap via contains (import may use ContainsRule).
DifferentSyrveMappingWarningOverlapping rule maps to another Syrve/iiko counterparty.
PriorityShadowingWarningOverlapping existing rule has lower priority number and may win first.

Preview uses the same effective priority as create (max(Priority) + 10 when omitted). Only active rules in the transaction organization/location scope are considered.

Response is bounded typed DTOs (CounterpartyRuleConflictPreviewResult); no EF entities, bank files, account numbers, or raw bank lines.

Duplicate protection in POST /api/counterparties/rules is unchanged and remains the source of truth for exact duplicates.

Rules directory / Rule management ​

Dedicated UI: /counterparty-rules (CounterpartyRulesPage.tsx).

Backend list: GET /api/counterparties/rules?organizationId=&locationId=&includeInactive=.

Manage endpoints:

  • PUT /api/counterparties/rules/{id} — edit pattern, Syrve/iiko counterparty, priority (scope fixed on rule).
  • POST /api/counterparties/rules/{id}/deactivate — soft disable (IsActive = false).
  • POST /api/counterparties/rules/{id}/activate — re-enable when no active duplicate in scope.

Active / inactive lifecycle ​

  • Import matching and GET /api/counterparties consider active rules only.
  • Deactivation does not delete rules or rewrite existing CounterpartyMatch history (MatchedRuleId remains on past auto-matches).
  • Inactive rules appear in directory when includeInactive=true.
  • Activate/update reject creating a second active duplicate in the same organization/outlet scope.

Audit / security ​

  • All write operations use AuditAction.Match, entity type CounterpartyMatchRule, typed audit details (rule id, scope, pattern, normalizedPattern, priority, Syrve fields, active state; update includes old/new values).
  • Write permission: scoped LocationCounterpartiesManage (same as wizard rule create).
  • Read/list uses scoped filter; responses expose organization/outlet ids only — no raw bank files, account numbers, or secrets.

Conflict preview scope ​

Conflict preview (POST /api/counterparties/rules/conflict-preview) remains wizard-only on /name-matching step 3. The rules directory does not duplicate MHPAY-69 conflict UI; duplicate protection on create is unchanged.

Priority ​

Within the same scope, rules are ordered by Priority: smaller numeric priority wins. If the request omits priority, backend assigns max(Priority) + 10 inside the same OrganizationId + LocationId scope. If there are no rules in the scope, the first automatic backend priority is 100.

Import rule precedence ​

Import matching uses the first matching active rule in this effective order (implemented in BankStatementImportService, consumed in list order by CounterpartyMatchExplainability.FindBestRuleMatch):

  1. Current organization + current outlet (OrganizationId + LocationId)
  2. Current organization global (OrganizationId + LocationId = null)
  3. Parent organization global rules (inherited, LocationId = null)
  4. Within each scope tier: ascending Priority (lower number wins)

Outlet-specific rules therefore win over organization-level rules even when the outlet rule has a higher (worse) priority number. Parent organization rules apply only when no closer-scope rule matches.

Import matching distinguishes:

  • ExactRule — raw candidate text equals rule Pattern (case-insensitive, trimmed)
  • NormalizedRule — normalized candidate equals NormalizedPattern
  • ContainsRule — normalized candidate contains NormalizedPattern but is not equal

Match types and operator reasons ​

CounterpartyMatchType values:

TypeOperator reason (Russian)
ExactRuleСовпало по правилу
NormalizedRuleСовпало по нормализованному названию
ContainsRuleСовпало по правилу
FuzzyCandidateПохоже на контрагента, требуется проверка
ManualMatchedСопоставлено вручную
UnmatchedКонтрагент не найден

LowConfidence remains a query/UI state for legacy or future AutoMatched rows with confidenceScore < 0.85.

Explainability persistence ​

CounterpartyMatch stores explainability for stable API/history:

  • MatchType
  • Reason
  • MatchedSignalsJson — typed CounterpartyMatchSignals serialized via PayJsonSerializer
  • MatchedRuleId
  • MatchedRulePattern
  • NormalizedBankName

Migration: 20260624120000_CounterpartyMatchExplainability.

Central logic: CounterpartyMatchExplainability + CounterpartyMatchMapping in Application layer.

Manual match clears MatchedRuleId / MatchedRulePattern (rule fields belong to automatic matching only) and writes manualSelection into signals.

Operator Explanation ​

GET /api/name-matches and GET /api/counterparty-matches return DTO boundaries, not EF entities. Each row includes:

  • matchType
  • confidenceScore
  • reason — primary operator-facing Russian text
  • matchedSignals — technical details (rule pattern, normalized bank name, flags exactMatch / normalizedMatch / containsMatch, etc.)
  • matchedRuleId, matchedRulePattern, normalizedBankName
  • explanation — backward-compatible alias of reason on name-matches list

The backend owns reason generation so the UI does not duplicate matching rules.

Audit Trail ​

Manual matching and rule creation both write audit entries with AuditAction.Match.

Rule creation uses entity type CounterpartyMatchRule and typed audit details containing the source bank transaction, pattern, normalized pattern, Syrve/iiko counterparty fields, and priority. Audit details are serialized through the project Newtonsoft-based serializer policy.

UI ​

The /name-matching page shows summary counters, status filters, a transaction table, operator reason (with explanation fallback), muted technical matchedSignals, a small matchType badge, and the Counterparty Match Wizard (CounterpartyMatchWizard) opened from each row action button.

The wizard replaces the previous separate manual-match and create-rule modals with a guided four-step flow (review → counterparty → rule decision → completion). Rule step shows editable pattern, normalized preview, conflict preview panel, selected Syrve/iiko counterparty, and priority. Success messages:

  • created: Правило создано. Следующие импорты будут сопоставляться автоматически.
  • duplicate: Такое правило уже существует. Повторное правило не создано.
  • skipped: Ручное сопоставление сохранено. Правило не создано.

The page also shows an active counterparty rules section backed by GET /api/counterparties. TanStack Query invalidates counterparties, counterparty-matches, name-matches, and dashboard after wizard mutations; the page does not reload.