Design Document: IA-Matching Surcharge Rule (DT-9358)

Overview

The IA-Matching Surcharge Rule is a configurable, per-client-contract pricing rule that applies a surcharge to companion components (e.g., Good Standing) that do not share the same Issuing Authority (IA) Office as any available primary component (e.g., Health License / Membership Registration). The surcharged companion stays classified as "base" but receives an additional charge equal to its own component price.

This introduces a third pricing element on top of the existing model:

VRPC Total = Base Price + Σ Additional Component Prices + Σ Surcharge Prices

The rule plugs into the existing rebalance pipeline in verification-request rather than adding a new external API. It reuses proven infrastructure — CaseDetailsInsufficiency for post-payment underpayment, RefundPayout for post-payment overpayment/refund, and VerificationRequestPayment.verificationPayments[] for payment inclusion — so startPaymentTransaction is never modified. A new SurchargePayment entity (owned entirely by verification-request, in its own DynamoDB table) tracks each surcharge lifecycle. The whole feature is gated by the DT_9358_IA_MATCHING_SURCHARGE_ENABLED feature flag in JayaVijaya KV and is a no-op when the rule is not configured on a client contract.

This document combines a High-Level Design (architecture, sequence flows, components, data models) and a Low-Level Design (algorithms, pseudocode, function signatures, entity definitions), plus Correctness Properties for property-based testing. It synthesizes the DT-9358 v2 tech-design set (product brief, technical design, implementation design, v1-vs-v2 diff) and is grounded in the existing code in verification-request, verification-workflow-engine, garuda, and core-verification-data-structure-library.


Part A — High-Level Design

Architecture

[Diagram]

Design principle (v2 over v1): minimize blast radius. Zero new entity classes in the shared core library (enum values + one RuleResult field only), zero new external API endpoints, zero new workflow handlers or sub-types, and no change to startPaymentTransaction.

A.2 Cross-Service Impact

Service / Repo Impact Changes
core-verification-data-structure-library LOW Add enum values (CaseDetailsInsufficiencyType.SURCHARGE_PAYMENT, RefundPayoutType.SURCHARGE_REVERSAL, RefundPayoutType.REBALANCE_OVERPAYMENT, CommonRuleNames.IA_MATCHING_SURCHARGE_RULE, LedgerTransactionSubType.REBALANCE_SWAP_OUT/REBALANCE_SWAP_IN) and add RuleResult.surchargedComponentIds. No new entity classes.
verification-request HIGH New IAMatchingSurchargeRule, new SurchargePayment module (entity/accessor/dynamo accessor/service/controller), smart overflow in ClaimClassificationUtils, RebalanceOptimizer, surcharge VRPC creation + post-payment delta handling in PaymentContractService, trigger on IA change in ClaimItemService, surchargedComponentIds extraction in EvaluateRulesService, rule registration in RuleRegistry.
verification-workflow-engine MEDIUM New SURCHARGE_PAYMENT case in CaseDetailsInsufficiencyTaskHandler.isInsufficiencyResolved(); RefundHelper filter includes SURCHARGE_REVERSAL + REBALANCE_OVERPAYMENT. No new files.
garuda (BFF) LOW-MEDIUM Surcharge line items in pricing breakdown (priceDetails.controller), SURCHARGE_PAYMENT follow-up display (applicantFollowup.helper).
brahma (applicant portal) LOW-MEDIUM Surcharge line item in pricing, SURCHARGE_PAYMENT insufficiency payment screen, locale strings.

Components and Interfaces

Component 1: IAMatchingSurchargeRule (verification-request)

Purpose: Evaluate base-classified companions against base-classified primaries and return the component IDs that must be surcharged. Responsibilities: feature-flag gate, exclusion filtering (trusted/blocked/bundle/RT/cancelled/no-IA/report-sharing VR), two-step 1:1 matching with consumption, populate RuleResult.surchargedComponentIds.

Component 2: RebalanceOptimizer (verification-request)

Purpose: Choose the cost-optimal base/additional assignment plus max-weight companion matching on the rebalance path. Responsibilities: exhaustive base-primary selection, exact max-weight companion matching per candidate, deterministic churn-minimizing tie-break, min-cost-flow fallback above a safety cap.

Component 3: ClaimClassificationUtils.getCategorizedClaimItems (verification-request)

Purpose: Categorize claim items into base vs additional. Responsibilities: existing FIFO categorization plus IA-aware smart overflow — overflow non-matching companions first (newest first), then matching companions (oldest first).

Component 4: PaymentContractService (verification-request)

Purpose: Orchestrate surcharge lifecycle within rebalanceClaimsInBaseItemPaymentContract. Responsibilities: create/cancel/refund surcharge VRPCs + SurchargePayment entities; post-payment base/additional reclassification guard; Approach A actual swap + single unified net delta; ledger swap entries; raise CaseDetailsInsufficiency / RefundPayout.

Component 5: SurchargePayment module (verification-request)

Purpose: Persist and query surcharge lifecycle state. Responsibilities: CRUD over the SurchargePayment DynamoDB table; status transitions (PENDING → PAID | CANCELLED | REFUNDED); internal read API by id / VR / claim item.

Component 6: CaseDetailsInsufficiencyTaskHandler (verification-workflow-engine)

Purpose: Resolve SURCHARGE_PAYMENT insufficiencies. Responsibilities: in isInsufficiencyResolved(), resolve when the surcharge VRPC is SUCCESS or the SurchargePayment is CANCELLED.

Component 7: RefundHelper (verification-workflow-engine)

Purpose: Pick up surcharge/reclassification refunds. Responsibilities: include SURCHARGE_REVERSAL and REBALANCE_OVERPAYMENT in the refund payout filter so the existing refund bot processes them after COMPLETED.

Interface contracts

// verification-request: rule contract (extends existing BaseRule)
interface IAMatchingSurchargeRuleParams {
  matchConfig: MatchConfig[];
}
interface MatchConfig {
  primaryComponentTypeIds: string[];
  companionComponentTypeIds: string[];   // ARRAY/SET — multiple companion types supported
  matchField?: string;                   // informational; matching is fixed two-step
}

// core library additions consumed cross-service
interface RuleResult {
  // ...existing fields...
  surchargedComponentIds?: ComponentIdString[];
}

Data Models

SurchargePayment (verification-request only; own DynamoDB table)

enum SurchargePaymentStatus {
  PENDING = 'PENDING',
  PAID = 'PAID',
  CANCELLED = 'CANCELLED',
  REFUNDED = 'REFUNDED',
}

interface SurchargePaymentMetadata {
  matchField: string;              // e.g. "resolvedIssuingAuthorityOfficeId|unresolvedIssuingAuthorityOfficeId"
  primaryClaimItemIds: string[];   // primaries evaluated against
  reason: string;                  // human-readable mismatch reason
}

class SurchargePayment {
  id: string;                      // partition key
  verificationRequestId: string;   // GSI-1
  claimItemId: string;             // GSI-2 (the surcharged companion)
  componentTypeId: string;
  vrpcId: string;                  // VRPC created for the surcharge
  overallPrice: Price;             // { amount, currency }
  status: SurchargePaymentStatus;
  metadata: SurchargePaymentMetadata;
  createdAt: string;               // ISO
  updatedAt: string;               // ISO
}

DynamoDB table

Table: SurchargePayment
  Partition Key: id (String)
  GSI-1: verificationRequestId-index  (PK: verificationRequestId)
  GSI-2: claimItemId-index            (PK: claimItemId)
Attributes: id, verificationRequestId, claimItemId, componentTypeId, vrpcId,
            overallPrice {amount, currency}, status, metadata, createdAt, updatedAt

Validation rules

Rule Configuration (on Client Contract)

{
  "ruleName": "IA_MATCHING_SURCHARGE_RULE",
  "ruleType": "REBALANCE",
  "params": {
    "matchConfig": [
      {
        "primaryComponentTypeIds": [
          "ComponentType@healthLicense",
          "ComponentType@membershipRegistration"
        ],
        "companionComponentTypeIds": ["ComponentType@goodStanding"]
      }
    ]
  }
}

Core library changes (enum values + one field; no new classes)

Grounded in existing files:

A.5 Sequence Flows

Flow 1 — Pre-payment surcharge evaluation on rebalance

[Diagram]

Flow 2 — Post-payment underpayment (IA change makes companion mismatch)

[Diagram]

Flow 3 — Post-payment overpayment / refund (IA change makes companion match)

[Diagram]

A.6 Post-Payment Lifecycle (summary)

BEFORE PAYMENT
  IA mismatch -> surcharge VRPC -> included in payment total

AFTER PAYMENT, IA -> mismatch (surcharge)
  SurchargePayment(PENDING) -> CaseDetailsInsufficiency(SURCHARGE_PAYMENT)
  -> applicant pays -> resolved -> resume

AFTER PAYMENT, IA -> match (surcharge refund)
  SurchargePayment(REFUNDED) -> RefundPayout(SURCHARGE_REVERSAL) -> refund bot after COMPLETED

AFTER PAYMENT, reclassification underpay (delta > 0)
  ADDITIONAL_PAYMENT VRPC for delta -> CaseDetailsInsufficiency -> applicant pays -> resume

AFTER PAYMENT, reclassification overpay (delta < 0)
  RefundPayout(REBALANCE_OVERPAYMENT) for delta -> refund bot after COMPLETED

AFTER PAYMENT, assignment changed but delta == 0
  ledger / orderVsFeesBreakup reconciliation only (no payment action)

Error Handling

Scenario Condition Response Recovery
Feature flag off DT_9358_IA_MATCHING_SURCHARGE_ENABLED disabled Rule returns { surchargedComponentIds: [] } No surcharge; zero behavior change
Rule not configured No IA_MATCHING_SURCHARGE_RULE on contract Pipeline unchanged No-op by design
Migration/additional-only VR getCategorizedClaimItems returns empty base set Rule produces zero results No-op by design
No IA identifier on companion neither resolved nor unresolved IA Skip evaluation (do NOT surcharge) Re-evaluated on later IA change
Base VRPC already paid paymentStatus === SUCCESS Compute unified net delta, do not mutate paid amount Insufficiency (delta>0) or refund (delta<0)
Selection space explosion primary selection space > MAX_SELECTION_SPACE Log + min-cost-flow fallback Deterministic optimal assignment
Ledger imbalance after swap credits − debits + reversals ≠ 0 Assertion fails; abort rebalance Investigated as data-integrity error

Testing Strategy

Unit tests: matching algorithm (two-step, consumption), exclusion filters, smart overflow ordering, delta computation, optimizer counterexamples, ledger invariant, insufficiency resolution.

Property-based tests (see Part C): library fast-check (TypeScript). Properties cover matching invariants, cost optimality, determinism/idempotency, exclusion soundness, and ledger balance.

Integration tests: full rebalance path (pre- and post-payment), insufficiency create/resolve, refund create/pickup, cross-type swap worked scenario, feature-flag off no-op.

A.9 Security, Performance, Dependencies

Security: no new external API surface; internal SurchargePayment read API is service-to-service. Trusted/blocked components are excluded so a surcharge is never charged for a component that will not appear in the report.

Performance: the optimizer runs on the non-hot rebalance path. Complexity is Π_T C(n_T, maxCount_T) primary selections × one small exact matching each — negligible for realistic per-VR counts (≤ ~10). A safety cap triggers min-cost-flow fallback. Trusted/blocked checks default to stored fields (Option A) for zero extra API calls.

Dependencies: @dfg/core-verification-data-structure-library (enum + RuleResult additions), DynamoDB (new SurchargePayment table), JayaVijaya KV (feature flag), existing refund bot and payment infrastructure.


Part B — Low-Level Design

B.1 Matching Algorithm — Canonical (1:1 Pairing with Consumption)

This is the canonical source of truth for surcharge matching. Companions are processed in createdAt order. Each primary can absorb exactly one companion, then it is consumed.

// Input:  basePrimaries[], baseCompanions[] (sorted ascending by createdAt)
// Output: surchargedComponentIds[]
function evaluateSurcharge(
  basePrimaries: ClaimItem[],
  baseCompanions: ClaimItem[]
): ComponentIdString[] {
  const surchargedComponentIds: ComponentIdString[] = [];
  const availablePrimaries = [...basePrimaries];

  for (const companion of baseCompanions) {
    // Companions are already filtered (see B.3); this loop only pairs.

    // Step 1: exact resolvedIssuingAuthorityOfficeId match
    let matchIndex = availablePrimaries.findIndex(
      p => p.resolvedIssuingAuthorityOfficeId
        && companion.resolvedIssuingAuthorityOfficeId
        && p.resolvedIssuingAuthorityOfficeId === companion.resolvedIssuingAuthorityOfficeId
    );

    // Step 2: fallback normalized unresolvedIssuingAuthorityOfficeId text match
    if (matchIndex < 0) {
      matchIndex = availablePrimaries.findIndex(
        p => p.unresolvedIssuingAuthorityOfficeId
          && companion.unresolvedIssuingAuthorityOfficeId
          && normalize(p.unresolvedIssuingAuthorityOfficeId)
             === normalize(companion.unresolvedIssuingAuthorityOfficeId)
      );
    }

    if (matchIndex >= 0) {
      availablePrimaries.splice(matchIndex, 1); // consume the primary; no surcharge
    } else {
      surchargedComponentIds.push(companion.componentId);
    }
  }
  return surchargedComponentIds;
}

function normalize(text: string): string {
  return text.trim().toLowerCase();
}

Matching priority. (1) exact resolvedIssuingAuthorityOfficeId; (2) normalized text on unresolvedIssuingAuthorityOfficeId (trim().toLowerCase()). Both sides must have the relevant field populated for a step to succeed. The unresolved field is always available as a fallback regardless of whether resolved IDs exist. Matching is always 1:1 — there is no configurable count per pairing.

B.2 IAMatchingSurchargeRule (verification-request)

File: src/modules/ClientContractRuleEvaluator/CommonRules/IAMatchingSurchargeRule.ts Registration: add to RuleNameClassMap in src/modules/ClientContractRuleEvaluator/RuleRegistry.ts ([CommonRuleNames.IA_MATCHING_SURCHARGE_RULE]: IAMatchingSurchargeRule).

export default class IAMatchingSurchargeRule extends BaseRule<IAMatchingSurchargeRuleParams> {
  static ruleName: string = CommonRuleNames.IA_MATCHING_SURCHARGE_RULE;

  async evaluate(metaData: RuleEvaluationMetaData): Promise<RuleResult> {
    const isEnabled = await this.featureFlagService.isEnabled('DT_9358_IA_MATCHING_SURCHARGE_ENABLED');
    if (!isEnabled) return { surchargedComponentIds: [] };

    const { claimItems, ruleParams, verificationRequest } = metaData;

    // Pre-filter: exclude claim items whose parent VR is a report-sharing type (VR-level exclusion).
    const reportSharingVrIds = await this.getReportSharingVrIds(verificationRequest);
    const eligible = claimItems.filter(ci => !reportSharingVrIds.includes(ci.verificationRequestId));

    const surchargedComponentIds: ComponentIdString[] = [];

    for (const config of ruleParams.matchConfig) {
      const basePrimaries = eligible.filter(item => this.isEligiblePrimary(item, config));
      const baseCompanions = eligible
        .filter(item => this.isEligibleCompanion(item, config))
        .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());

      surchargedComponentIds.push(...evaluateSurcharge(basePrimaries, baseCompanions));
    }
    return { surchargedComponentIds };
  }

  private normalize(text: string): string { return text.trim().toLowerCase(); }

  private async getReportSharingVrIds(vr: VerificationRequest): Promise<string[]> {
    const linked = vr.linkedVerificationRequestIds ?? [];
    if (linked.length === 0) return [];
    const linkedVrs = await this.verificationRequestAccessor.getByIds(linked);
    return linkedVrs.filter(v => v.reportSharingType != null).map(v => v.id);
  }
}

B.3 Exclusion Filtering (Preconditions)

Trusted/blocked status uses Option A (stored fields) by default — claimItem.isTrusted and claimItem.metadata.isClaimItemBlocked are set on every claim mutation (zero extra API calls). Option B (live checkComponentTrustedAndBlockedStatus) is available when freshness is critical.

// Primary eligibility
function isEligiblePrimary(item: ClaimItem, config: MatchConfig): boolean {
  return config.primaryComponentTypeIds.includes(item.componentTypeId)
    && !item.isAdditionalComponent                                        // base-classified
    && !item.isTrusted
    && !item.metadata?.isClaimItemBlocked                                 // blocked
    && !item.bundleConfigId                                               // bundle excluded
    && (item.resolvedIssuingAuthorityOfficeId || item.unresolvedIssuingAuthorityOfficeId)
    && item.claimStatus !== ClaimStatus.CANCELLED;                        // cancelled/deleted excluded
}

// Companion eligibility
function isEligibleCompanion(item: ClaimItem, config: MatchConfig): boolean {
  return config.companionComponentTypeIds.includes(item.componentTypeId)
    && !item.isAdditionalComponent                                        // base-classified
    && !item.isTrusted
    && !item.metadata?.isClaimItemBlocked
    && !item.bundleConfigId
    && (item.resolvedIssuingAuthorityOfficeId || item.unresolvedIssuingAuthorityOfficeId)
    && item.isVerificationRequired !== false                             // RT excluded (component level)
    && item.claimStatus !== ClaimStatus.CANCELLED;
}
Exclusion Level Reason
bundleConfigId set component Bundle pricing handled separately
RT (isVerificationRequired === false) component Report-transferred
Report-sharing VR VR Entire VR excluded from comparison
Cancelled/deleted primary component Not available for matching
Trusted (isTrusted) component Bypasses surcharge
Blocked (isClaimItemBlocked) component Will not appear in report
No IA identifier (neither resolved nor unresolved) component Cannot be evaluated

B.4 Smart Overflow — getCategorizedClaimItems (verification-request)

File: src/modules/ClientContractRuleEvaluator/utils/ClaimClassificationUtils.ts Existing signature (grounded): static async getCategorizedClaimItems(verificationRequestDetails, clientContract): Promise<{ baseClaimItems, additionalClaimItems }>.

When companions in base exceed maxCount, overflow with IA awareness to minimize surcharge exposure.

// After existing FIFO allocation decides companionsInBase:
if (companionsInBase.length > maxCount) {
  const matchingCompanions: ClaimItem[] = [];
  const nonMatchingCompanions: ClaimItem[] = [];
  const tempPrimaries = [...basePrimaries];

  for (const companion of companionsInBase) {
    // Keep non-evaluable companions in base (they are not surcharge candidates)
    if (!companion.resolvedIssuingAuthorityOfficeId || companion.isTrusted || companion.bundleConfigId) {
      matchingCompanions.push(companion);
      continue;
    }
    const idx = tempPrimaries.findIndex(
      p => p.resolvedIssuingAuthorityOfficeId === companion.resolvedIssuingAuthorityOfficeId
    );
    if (idx >= 0) { tempPrimaries.splice(idx, 1); matchingCompanions.push(companion); }
    else { nonMatchingCompanions.push(companion); }
  }

  const overflow: ClaimItem[] = [];
  let remaining = companionsInBase.length - maxCount;

  // Non-matching first: sort ASC by createdAt, pop() overflows NEWEST first (oldest stay in base).
  const sortedNonMatching = nonMatchingCompanions.sort(
    (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
  );
  while (remaining > 0 && sortedNonMatching.length > 0) { overflow.push(sortedNonMatching.pop()!); remaining--; }

  // Then matching fallback: sort DESC by createdAt, pop() overflows OLDEST first (newest stay in base).
  if (remaining > 0) {
    const sortedMatching = matchingCompanions.sort(
      (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
    );
    while (remaining > 0 && sortedMatching.length > 0) { overflow.push(sortedMatching.pop()!); remaining--; }
  }

  for (const item of overflow) item.isAdditionalComponent = true;
}

Rationale. Matching companions stay in base (no surcharge). Non-matching companions overflow to additional first — they pay the component price either way, so moving them out avoids a surcharge on top. Oldest non-matching stay longest (more likely completed processing); newest matching stay longest (minimize surcharge exposure on recently submitted items).

B.5 RebalanceOptimizer (verification-request)

File: src/modules/PaymentContract/RebalanceOptimizer.ts

Cost model

cost    = basePrice + Σ price(p) for each primary p NOT in base + Σ price(c) for each companion c NOT matched-in-base
savings = Σ price(p)·[p in base] + Σ price(c)·[c matched-in-base]        // minimize cost ≡ maximize savings

A companion is matched-in-base iff it occupies a base slot AND is 1:1 paired (exact IA match) with a base primary. A base-unmatched companion costs the same as an additional companion. A primary is never surcharged — it is free in base and provides value twice (its saved price + ability to absorb one matching companion).

Constraints

Constraint Rule
Base primaries per type T #(base primaries of T) ≤ maxCount_T
Matched companions per type T #(matched companions of T) ≤ maxCount_T
Matching cardinality 1:1; edge iff exact IA match (resolved id or normalized unresolved text)
Companion types array/set — multiple types, each with own price and maxCount

Why greedy fails (jointly coupled — max-weight assignment, not greedy)

Algorithm — bounded exhaustive + exact matching

interface Assignment {
  basePrimaryIds: string[];
  additionalPrimaryIds: string[];
  matchedCompanionIds: string[];      // matched-in-base
  unmatchedCompanionIds: string[];    // surcharged / base-unmatched (pay component price)
  matching: Array<{ primaryId: string; companionId: string }>;
  savings: number;
}

class RebalanceOptimizer {
  private static readonly MAX_SELECTION_SPACE = 100_000;

  optimize(
    primariesByType: Map<string, ClaimItem[]>,
    companions: ClaimItem[],
    maxCountByType: Map<string, number>,
    priceOf: (item: ClaimItem) => number,
    current: Assignment,
  ): Assignment {
    if (this.estimateSelectionSpace(primariesByType, maxCountByType) > RebalanceOptimizer.MAX_SELECTION_SPACE) {
      this.logger.warn('Rebalance selection space too large; min-cost-flow fallback');
      return this.minCostFlowFallback(primariesByType, companions, maxCountByType, priceOf, current);
    }

    let best: Assignment | null = null;
    for (const basePrimaries of this.enumerateBaseSelections(primariesByType, maxCountByType)) {
      const primarySavings = basePrimaries.reduce((s, p) => s + priceOf(p), 0);
      const { matchSavings, matching } = this.maxWeightCompanionMatch(basePrimaries, companions, maxCountByType, priceOf);
      const total = primarySavings + matchSavings;
      const candidate = this.buildAssignment(basePrimaries, companions, matching, total);

      if (best === null
          || total > best.savings
          || (total === best.savings && this.churn(candidate, current) < this.churn(best, current))) {
        best = candidate;
      }
    }
    return best ?? current;
  }

  // Max-weight bipartite matching, edge weight = price(companion) when IA matches,
  // subject to per-companion-type slot caps. Solve EXACTLY (Hungarian / min-cost flow).
  // Weight-greedy is intentionally avoided (Counterexample 3).
  private maxWeightCompanionMatch(...): { matchSavings: number; matching: Array<{ primaryId: string; companionId: string }> } { /* exact solver */ }

  // Number of base<->additional membership flips between two assignments.
  private churn(candidate: Assignment, current: Assignment): number {
    const curr = new Set([...current.basePrimaryIds, ...current.matchedCompanionIds]);
    const cand = new Set([...candidate.basePrimaryIds, ...candidate.matchedCompanionIds]);
    let flips = 0;
    for (const id of curr) if (!cand.has(id)) flips++;
    for (const id of cand) if (!curr.has(id)) flips++;
    return flips;
  }
}

Complexity. Π_T C(n_T, maxCount_T) × one small exact matching each — negligible for N ≤ ~10. Determinism. Tie-break by churn, then createdAt, then claim id → re-running on unchanged input produces identical output (no phantom swaps). If current is already optimal, churn = 0 and nothing changes.

B.6 PaymentContractService — Surcharge Processing (verification-request)

File: src/modules/PaymentContract/PaymentContract.service.ts — within rebalanceClaimsInBaseItemPaymentContract().

async processSurchargeResults(
  verificationRequestId: string,
  surchargedComponentIds: string[],
  claimItems: ClaimItem[],
  basePrimaries: ClaimItem[],
  clientContract: ClientContract,
  existingPaymentCompleted: boolean,
): Promise<void> {
  const existing = await this.surchargePaymentService.getSurchargePaymentsByVRId(verificationRequestId);
  const existingActiveIds = existing
    .filter(s => s.status === SurchargePaymentStatus.PENDING || s.status === SurchargePaymentStatus.PAID)
    .map(s => s.claimItemId);

  const newIds = surchargedComponentIds.filter(id => !existingActiveIds.includes(id));
  const removedIds = existingActiveIds.filter(id => !surchargedComponentIds.includes(id));

  // NEW surcharges: create VRPC + SurchargePayment, add VRPC to payment
  for (const claimItemId of newIds) {
    const claimItem = claimItems.find(ci => ci.id === claimItemId)!;
    const price = await this.getVerificationComponentPriceByContractIdAndComponentId(
      clientContract.id, claimItem.componentTypeId);

    const vrpc = await this.createVRPC({
      verificationRequestId, paidAmount: price, paymentStatus: PaymentStatus.PENDING,
      metadata: { type: 'SURCHARGE', claimItemId, componentTypeId: claimItem.componentTypeId },
    });
    await this.addVRPCToPayment(verificationRequestId, vrpc.id);

    await this.surchargePaymentService.createSurchargePayment({
      verificationRequestId, claimItemId, componentTypeId: claimItem.componentTypeId,
      vrpcId: vrpc.id, overallPrice: price, status: SurchargePaymentStatus.PENDING,
      metadata: {
        matchField: 'resolvedIssuingAuthorityOfficeId|unresolvedIssuingAuthorityOfficeId',
        primaryClaimItemIds: basePrimaries.map(p => p.id),
        reason: 'Companion IA does not match any available primary IA (resolved or unresolved)',
      },
    });

    if (existingPaymentCompleted) await this.createSurchargeInsufficiency(verificationRequestId, claimItemId);
  }

  // REMOVED surcharges: cancel (PENDING) or refund (PAID)
  for (const claimItemId of removedIds) {
    const sp = existing.find(s => s.claimItemId === claimItemId);
    if (!sp) continue;
    if (sp.status === SurchargePaymentStatus.PENDING) {
      await this.surchargePaymentService.cancelSurchargePayment(sp.id);
      await this.cancelVRPC(sp.vrpcId);
    } else if (sp.status === SurchargePaymentStatus.PAID) {
      await this.surchargePaymentService.markAsRefunded(sp.id);
      await this.createSurchargeRefund(sp);
    }
  }
}

private async createSurchargeInsufficiency(verificationRequestId: string, claimItemId: string): Promise<void> {
  const sp = await this.surchargePaymentService.getSurchargePaymentByClaimItemId(claimItemId);
  await this.caseDetailsInsufficiencyService.create({
    type: CaseDetailsInsufficiencyType.SURCHARGE_PAYMENT,
    typeId: sp!.id,
    status: InsufficiencyStatus.OPEN,
    entityId: claimItemId,
    verificationRequestId,
    isParentLevelInsufficiency: false,
  });
}

private async createSurchargeRefund(sp: SurchargePayment): Promise<void> {
  const refundContract = await this.refundContractService.createRefundContractForPaymentContract(sp.vrpcId);
  await this.refundPayoutService.create({
    type: RefundPayoutType.SURCHARGE_REVERSAL,
    paidAmount: sp.overallPrice,
    refundPaymentContractIds: [refundContract.id],
    status: RefundPayoutStatus.CREATED,
    verificationRequestId: sp.verificationRequestId,
    refundDetails: { reason: 'Surcharge reversed - IA now matches primary component' },
  });
}

B.7 Post-Payment Reclassification — Approach A (actual swap + unified net delta)

When the optimizer's chosen assignment differs from the current paid assignment, actually swap classifications, keep entities consistent, and settle one unified net delta per rebalance (supersedes handling base-amount changes and surcharge changes as two separate mechanisms).

async applyRebalanceAssignment(
  verificationRequestId: string, target: Assignment, current: Assignment, baseVRPC: VRPC,
): Promise<void> {
  const flips = this.diffFlips(current, target);

  // Preserve original breakdown exactly once
  if (!baseVRPC.metadata.originalOrderVsFeesBreakup) {
    baseVRPC.metadata.originalOrderVsFeesBreakup = baseVRPC.orderVsFeesBreakup;
  }

  for (const flip of flips) {
    await this.claimItemService.setIsAdditionalComponent(flip.claimItemId, flip.nowAdditional);
    this.applyBreakupMembership(baseVRPC, flip);

    if (flip.leftAdditionalSet) {
      await this.ledgerService.addEntry({
        vrpcId: baseVRPC.id, type: LedgerTransactionType.REVERSAL,
        subType: LedgerTransactionSubType.REBALANCE_SWAP_OUT, amount: flip.amount, claimItemId: flip.claimItemId,
      });
    }
    if (flip.enteredAdditionalSet) {
      await this.ledgerService.addEntry({
        vrpcId: baseVRPC.id, type: LedgerTransactionType.DEBIT,
        subType: LedgerTransactionSubType.REBALANCE_SWAP_IN, amount: flip.amount, claimItemId: flip.claimItemId,
      });
    }
  }
  await this.updateVRPC(baseVRPC);
  await this.assertLedgerBalanced(baseVRPC.id); // invariant: credits - debits + reversals = 0

  // Unified net delta (ONE per rebalance)
  const newTotal = baseVRPC.basePrice
    + this.sumComponentPrices(target.additionalPrimaryIds.map(this.itemById))
    + this.sumComponentPrices(target.additionalCompanionIds.map(this.itemById))
    + this.sumSurcharge(target.unmatchedCompanionIds.map(this.itemById));
  const paidTotal = await this.currentPaidTotal(verificationRequestId); // base VRPC + surcharge VRPCs
  const delta = newTotal - paidTotal;

  if (delta > 0)      await this.handleReclassificationUnderpayment(verificationRequestId, delta, baseVRPC);
  else if (delta < 0) await this.handleReclassificationOverpayment(verificationRequestId, Math.abs(delta), baseVRPC);
  // delta === 0 but assignment changed: ledger / orderVsFeesBreakup reconciliation only (done above)
}

Guard when base VRPC not yet paid (existing behavior)

if (baseVRPC.paymentStatus === PaymentStatus.PENDING) {
  baseVRPC.paidAmount = { amount: baseItem.finalPrice + this.sumComponentPrices(newAdditional), currency: baseVRPC.paidAmount.currency };
  await this.updateVRPC(baseVRPC);
  return;
}

Underpayment (delta > 0) and Overpayment (delta < 0)

private async handleReclassificationUnderpayment(vrId: string, delta: number, baseVRPC: VRPC): Promise<void> {
  const additionalVRPC = await this.createVRPC({
    verificationRequestId: vrId,
    paidAmount: { amount: delta, currency: baseVRPC.paidAmount.currency },
    paymentStatus: PaymentStatus.PENDING,
    metadata: { type: 'ADDITIONAL_PAYMENT', reason: 'RECLASSIFICATION_DELTA' },
  });
  await this.addVRPCToPayment(vrId, additionalVRPC.id);
  await this.caseDetailsInsufficiencyService.create({
    type: CaseDetailsInsufficiencyType.SURCHARGE_PAYMENT, typeId: additionalVRPC.id,
    status: InsufficiencyStatus.OPEN, entityId: vrId, verificationRequestId: vrId, isParentLevelInsufficiency: false,
  });
}

private async handleReclassificationOverpayment(vrId: string, absDelta: number, baseVRPC: VRPC): Promise<void> {
  const refundContract = await this.refundContractService.createRefundContractForPaymentContract(
    baseVRPC.id, { refundAmount: { amount: absDelta, currency: baseVRPC.paidAmount.currency } });
  await this.refundPayoutService.create({
    type: RefundPayoutType.REBALANCE_OVERPAYMENT,
    paidAmount: { amount: absDelta, currency: baseVRPC.paidAmount.currency },
    refundPaymentContractIds: [refundContract.id], status: RefundPayoutStatus.CREATED,
    verificationRequestId: vrId,
    refundDetails: { reason: 'Base/additional reclassification after payment - overpayment delta' },
  });
}

Worked scenario — cross-type swap changes the base amount

maxCount_HL = maxCount_MR = maxCount_GS = 1. HL ($30, ia1) base, MR ($50, ia2) additional, GS (ia2) base.

Step Assignment Matching Base additional-sum Surcharge Net
Current (paid) base {HL(ia1)}, additional {MR(ia2)}, GS base GS(ia2) vs {HL(ia1)} → no match +$50 (MR) $20 (GS)
Rebalance base {MR(ia2)}, additional {HL(ia1)}, GS base GS(ia2) matches MR(ia2) → match +$30 (HL) $0

Base additional-sum drops $20 (MR→HL) and surcharge removed −$20 → net delta −$40 → refund $40. Confirms a single unified delta (not two independent mechanisms) is required.

B.8 Trigger on IA Change (verification-request)

File: src/modules/ClaimItem/ClaimItem.service.ts — within updateClaimItem().

const isIaChanged =
  updated.resolvedIssuingAuthorityOfficeId   !== previous.resolvedIssuingAuthorityOfficeId ||
  updated.unresolvedIssuingAuthorityOfficeId !== previous.unresolvedIssuingAuthorityOfficeId;

if (isIaChanged && updated.isVerificationRequired) {
  await this.handlePaymentPostClaimItemUpdate(/* existing IA fee handling */);
  await this.paymentContractService.rebalanceClaimsInBaseItemPaymentContract(updated.verificationRequestId); // NEW
}

Trigger points: rebalance call (Garuda pricing screen rebalceClaimsAndPaymentContracts), IA change (ClaimItemService.updateClaimItem), claim addition, claim cancellation/deletion.

B.9 EvaluateRulesService — extract surchargedComponentIds (verification-request)

File: src/modules/ClientContractRuleEvaluator/EvaluateRulesService.ts

const surchargedComponentIds = ruleResults.flatMap(r => r.surchargedComponentIds ?? []);

if (surchargedComponentIds.length > 0) {
  const basePrimaries = claimItems.filter(item =>
    matchConfig.primaryComponentTypeIds.includes(item.componentTypeId)
      && !item.isAdditionalComponent
      && (item.resolvedIssuingAuthorityOfficeId || item.unresolvedIssuingAuthorityOfficeId)
      && !item.bundleConfigId
      && item.claimStatus !== ClaimStatus.CANCELLED);

  await this.paymentContractService.processSurchargeResults(
    verificationRequestId, surchargedComponentIds, claimItems, basePrimaries, clientContract, isPaymentCompleted);
}

B.10 verification-workflow-engine

CaseDetailsInsufficiencyTaskHandler — resolution case

File: src/handler/Insufficiency/CaseDetailsInsufficiencyTaskHandler.ts

case CaseDetailsInsufficiencyType.SURCHARGE_PAYMENT: {
  const sp = await this.verificationRequestServiceClient.getSurchargePaymentById(insufficiency.typeId);
  if (sp.status === SurchargePaymentStatus.CANCELLED) return true; // no longer needed
  const vrpc = await this.coreVDSClient.getVRPC(sp.vrpcId);
  return vrpc.paymentStatus === PaymentStatus.SUCCESS;
}

No new handler class, no new WorkflowSubType (reuses CASE_DETAILS_INSUFFICIENCY).

RefundHelper — include new refund types

File: src/helper/RefundHelper.ts

const eligibleRefunds = refundPayouts.filter(rp =>
  [
    RefundPayoutType.OVERPAYMENT,
    RefundPayoutType.CANCELLATION,
    RefundPayoutType.SURCHARGE_REVERSAL,     // NEW
    RefundPayoutType.REBALANCE_OVERPAYMENT,  // NEW
  ].includes(rp.type) && rp.status === RefundPayoutStatus.CREATED
);

B.11 SurchargePayment Module (verification-request)

Directory: src/modules/surchargePayment/SurchargePayment.entity.ts, SurchargePayment.accessor.ts, SurchargePaymentDynamo.accessor.ts, SurchargePayment.service.ts, SurchargePayment.controller.ts.

// Accessor
class SurchargePaymentDynamoAccessor extends SurchargePaymentAccessor {
  private tableName = 'SurchargePayment';
  async create(entity: SurchargePayment): Promise<SurchargePayment>;
  async getById(id: string): Promise<SurchargePayment>;
  async getByVerificationRequestId(vrId: string): Promise<SurchargePayment[]>;
  async getByClaimItemId(claimItemId: string): Promise<SurchargePayment[]>;
  async update(id: string, update: Partial<SurchargePayment>): Promise<SurchargePayment>;
}

// Service
class SurchargePaymentService {
  createSurchargePayment(input: CreateSurchargePaymentInput): Promise<SurchargePayment>;
  getSurchargePaymentsByVRId(vrId: string): Promise<SurchargePayment[]>;
  getSurchargePaymentByClaimItemId(claimItemId: string): Promise<SurchargePayment | null>;
  updateStatus(id: string, status: SurchargePaymentStatus): Promise<SurchargePayment>;
  cancelSurchargePayment(id: string): Promise<SurchargePayment>;   // -> CANCELLED
  markAsRefunded(id: string): Promise<SurchargePayment>;           // -> REFUNDED
  markAsPaid(id: string): Promise<SurchargePayment>;               // -> PAID
}

// Controller (internal read API)
@Controller('/surcharge-payment')
class SurchargePaymentController {
  @Get('/:id') getById(id: string): Promise<SurchargePayment>;
  @Get('/verification-request/:vrId') getByVRId(vrId: string): Promise<SurchargePayment[]>;
  @Get('/claim-item/:claimItemId') getByClaimItemId(claimItemId: string): Promise<SurchargePayment | null>;
}

B.12 garuda (BFF) and brahma (Applicant Portal)

garudapriceDetails.controller.ts: fetch surcharge payments for the VR and map non-cancelled ones to line items ("<ComponentType> (IA Mismatch Surcharge)", amount, claimItemId, status). applicantFollowup.helper.ts: handle CaseDetailsInsufficiencyType.SURCHARGE_PAYMENT → follow-up card (title "Additional Payment Required", amount, paymentContractId = surchargePayment.vrpcId).

brahma — pricing breakdown shows surcharge line item + status (PENDING/PAID/REFUNDED); SURCHARGE_PAYMENT insufficiency renders a payment screen; "Pay Now" reuses existing startPaymentTransaction (surcharge VRPC already in verificationPayments[]); add locale strings.

B.13 Edge Cases

B.14 Rollout

Feature flag DT_9358_IA_MATCHING_SURCHARGE_ENABLED (JayaVijaya KV). Deploy with flag OFF → create SurchargePayment table (staging) → configure test client contract → enable flag for test client → validate surcharge/insufficiency/refund → production table + gradual enablement → remove flag once stable. surchargedComponentIds defaults to empty; payment only includes surcharge when non-empty; existing pipelines unchanged for contracts without the rule.


Correctness Properties

Property-Based Testing Library: fast-check (TypeScript). Generators produce claim-item sets with random component types, IA identifiers (resolved/unresolved/none), flags (trusted/blocked/bundle/RT/cancelled), createdAt, and prices, plus random matchConfig and maxCount.

Property 1: Matching soundness (no false surcharge)

For every companion that shares an IA (resolved id, or normalized unresolved text) with at least one distinct available primary, there exists a 1:1 assignment in which it is not surcharged.

∀ companion c: (∃ distinct available primary p with iaMatch(p, c)) ⟹ c can be matched (not surcharged)

Property 2: 1:1 consumption

Each primary absorbs at most one companion; the number of matched companions never exceeds the number of primaries.

∀ primary p: |{c : matched(c, p)}| ≤ 1   ∧   |matchedCompanions| ≤ |basePrimaries|

Property 3: Exclusion soundness

No trusted, blocked, bundle (bundleConfigId), RT (isVerificationRequired === false), cancelled, or no-IA companion is ever surcharged; none participate as a primary. Report-sharing VR claim items are excluded entirely.

∀ c ∈ surchargedComponentIds: ¬trusted(c) ∧ ¬blocked(c) ∧ ¬bundle(c) ∧ ¬RT(c) ∧ ¬cancelled(c) ∧ hasIA(c) ∧ ¬reportSharingVR(c)

Property 4: Determinism / idempotency

Evaluating the same input twice yields identical surchargedComponentIds; running the optimizer on an already-optimal assignment yields churn = 0 and produces no swaps.

optimize(x, current=optimal) ⟹ churn = 0 ∧ assignment unchanged
evaluate(x) = evaluate(x)

Property 5: Cost optimality

The optimizer's chosen assignment has savings ≥ any other feasible assignment satisfying the per-type maxCount constraints and 1:1 matching.

∀ feasible assignment a: savings(optimizer(x)) ≥ savings(a)

Property 6: Pricing identity

VRPC Total = basePrice + Σ additional component prices + Σ surcharge prices
surcharge(c) = componentPrice(c)   for each base-unmatched companion c

Property 7: Smart overflow minimizes surcharge exposure

When companions exceed maxCount, no matching companion is overflowed while a non-matching companion remains in base.

overflow chosen ⟹ ¬(∃ matching companion overflowed ∧ ∃ non-matching companion kept in base)

Property 8: Unified net delta correctness (post-payment)

For a rebalance from a paid assignment, exactly one settlement occurs and it equals the true net:

delta = newTotal − paidTotal
delta > 0 ⟹ exactly one ADDITIONAL_PAYMENT/surcharge VRPC + one CaseDetailsInsufficiency(SURCHARGE_PAYMENT)
delta < 0 ⟹ exactly one RefundPayout for abs(delta)
delta = 0 ⟹ no payment action (ledger reconciliation only)

Property 9: Ledger balance invariant

After every rebalance swap, the base VRPC ledger balances:

credits − debits + reversals = 0

Property 10: Feature-flag / not-configured no-op

With the flag off or the rule not configured, surchargedComponentIds = [] and no surcharge VRPC, insufficiency, or refund is created (byte-for-byte identical to pre-feature behavior).

(¬flagEnabled ∨ ¬ruleConfigured) ⟹ surchargedComponentIds = [] ∧ no side effects

Property 11: Surcharge lifecycle transitions are valid

SurchargePayment.status only follows legal transitions:

PENDING → PAID | CANCELLED
PAID → REFUNDED
(no transition out of CANCELLED or REFUNDED)