Appearance
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
- Bank adapters create normalized
BankTransactionrecords. - During import,
BankStatementImportServiceloads active only (IsActive = true)CounterpartyMatchRulerows for the import scope once. - Rule scope includes the import
OrganizationId + LocationId, organization-level rules for the same organization (LocationId = null), and inherited parent-organization global rules (LocationId = null, parentOrganizationId). - Inactive rules are excluded from import matching. Deactivated rules do not auto-match new imports; existing
CounterpartyMatchhistory keeps priorMatchedRuleId. - For each transaction, the backend chooses
CounterpartyRawName; if it is empty, it falls back toRawDescription. - The candidate text is normalized and matched against active rule patterns using effective rule order (see Import rule precedence below).
- If a
MatchSupplierrule matches, the service createsCounterpartyMatchwithStatus = AutoMatched,ConfidenceScore = 1, selected Syrve/iiko counterparty fields,IsManual = false, and explainability fields (MatchType,Reason,MatchedSignalsJson,MatchedRuleId,MatchedRulePattern,NormalizedBankName). If anExcludeNonSupplierrule matches, it creates the same explainability link but setsStatus/MatchType = Excluded, clears supplier fields, and recordsExcludeCategory. - If no active rule matches, the service creates
CounterpartyMatchwithStatus = Unmatched,ConfidenceScore = 0,Reason = "Контрагент не найден", and explainability fields for the unmatched case (noMatchedRuleId/MatchedRulePattern). - Operators review matches on
/name-matching. - 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
Unmatchedmeans no active rule matched the normalized bank counterparty text.AutoMatchedmeans an active rule matched during import.ManualMatchedmeans an operator selected the Syrve/iiko counterparty.Excludedmeans 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 anExcludeNonSupplierrule, and reapplies it to similar rows.LowConfidenceis not a domain enum. It is a query/UI state forAutoMatchedrows withconfidenceScore < 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.
Indonesian legal prefixes and bank noise
The normalizer applies additional safe rules for Indonesian bank counterparty text:
PT./CV.punctuation is normalized toPT/CVwith a separating space;- glued prefixes such as
PTKOLBASAbecomePT KOLBASAwhen 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/CVare kept only when followed by a meaningful company-name token.
Examples:
| Raw bank text | Normalized candidate |
|---|---|
PT. KOLBASA | PT KOLBASA |
PTKOLBASA | PT KOLBASA |
BI FAST TRF PT. KOLBASA | PT KOLBASA |
CV. MAJU JAYA | CV 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
Patternnormalizes to an empty string, so generic rules likePTorCVcannot auto-match every Indonesian company; - rule creation rejects patterns that normalize to empty (
400with 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):
- Drawer header shows file, Syrve counterparty (if known), purpose, and required action — once.
- Match panel does not repeat bank counterparty / purpose.
- Searchable Syrve counterparty selector.
- 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.
- Advanced (collapsed): normalized pattern preview, priority.
- Conflict preview runs automatically; blocking conflict prevents rule creation but still allows manual match.
- Primary action Match — no completion screen; feedback + refresh; in sequential mode opens the next Attention row.
- 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:
- 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.
- Select Syrve/iiko counterparty — search/select from
GET /api/syrve/counterparties; submit viaPOST /api/counterparty-matches(useManualCounterpartyMatch). - 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 viaPOST /api/counterparties/rules/conflict-preview(useCounterpartyRuleConflictPreview) before save; submit viaPOST /api/counterparties/rules(useCreateCounterpartyRule). Duplicate response (alreadyExists = true) is a successful wizard outcome, not an error. - Completion — success message (manual match saved; rule created, duplicate reused, or rule skipped). TanStack Query invalidates
name-matches,counterparty-matches,counterparties, anddashboardwithout 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 = ManualMatchedConfidenceScore = 1IsManual = true- selected Syrve/iiko counterparty fields
MatchedByUserIdUpdatedAtUtc
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, otherwisebankName, otherwiserawDescription; - normalized preview;
- selected
syrveCounterpartyId; - selected
syrveCounterpartyName; - priority, defaulting to
100in the UI.
Backend endpoint: POST /api/counterparties/rules.
Rule fields:
OrganizationIdLocationIdPatternNormalizedPatternSyrveCounterpartyIdSyrveCounterpartyNameIsActivePriorityCreatedByUserId- 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:
| Type | Severity | Meaning |
|---|---|---|
ExactDuplicate | Info | Same scope + normalized pattern + Syrve counterparty; create returns existing rule (alreadyExists = true). |
DuplicateScopeDifferentCounterparty | Blocking | Same normalized pattern maps to a different Syrve/iiko counterparty in scope. |
OverlappingPattern | Warning | Normalized patterns overlap via contains (import may use ContainsRule). |
DifferentSyrveMapping | Warning | Overlapping rule maps to another Syrve/iiko counterparty. |
PriorityShadowing | Warning | Overlapping 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/counterpartiesconsider active rules only. - Deactivation does not delete rules or rewrite existing
CounterpartyMatchhistory (MatchedRuleIdremains 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 typeCounterpartyMatchRule, 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):
- Current organization + current outlet (
OrganizationId+LocationId) - Current organization global (
OrganizationId+LocationId = null) - Parent organization global rules (inherited,
LocationId = null) - 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 rulePattern(case-insensitive, trimmed)NormalizedRule— normalized candidate equalsNormalizedPatternContainsRule— normalized candidate containsNormalizedPatternbut is not equal
Match types and operator reasons
CounterpartyMatchType values:
| Type | Operator 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:
MatchTypeReasonMatchedSignalsJson— typedCounterpartyMatchSignalsserialized viaPayJsonSerializerMatchedRuleIdMatchedRulePatternNormalizedBankName
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:
matchTypeconfidenceScorereason— primary operator-facing Russian textmatchedSignals— technical details (rule pattern, normalized bank name, flagsexactMatch/normalizedMatch/containsMatch, etc.)matchedRuleId,matchedRulePattern,normalizedBankNameexplanation— backward-compatible alias ofreasonon 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.