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.
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.
| 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. |
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.
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.
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).
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.
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.
Purpose: Resolve SURCHARGE_PAYMENT insufficiencies.
Responsibilities: in isInsufficiencyResolved(), resolve when the surcharge VRPC is SUCCESS or
the SurchargePayment is CANCELLED.
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.
// 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[];
}
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
status ∈ {PENDING, PAID, CANCELLED, REFUNDED}.overallPrice.amount equals the companion's component price at creation time.vrpcId references a VRPC belonging to verificationRequestId.PENDING/PAID) SurchargePayment per surcharged claimItemId at a time.{
"ruleName": "IA_MATCHING_SURCHARGE_RULE",
"ruleType": "REBALANCE",
"params": {
"matchConfig": [
{
"primaryComponentTypeIds": [
"ComponentType@healthLicense",
"ComponentType@membershipRegistration"
],
"companionComponentTypeIds": ["ComponentType@goodStanding"]
}
]
}
}
Grounded in existing files:
core-verification-data-structure-library/src/VerificationRequest/CaseDetailsInsufficiency.ts →
add SURCHARGE_PAYMENT = 'SURCHARGE_PAYMENT' to CaseDetailsInsufficiencyType.core-verification-data-structure-library/src/Utils/enums.ts → add SURCHARGE_REVERSAL and
REBALANCE_OVERPAYMENT to RefundPayoutType; add REBALANCE_SWAP_OUT and REBALANCE_SWAP_IN to
LedgerTransactionSubType.CommonRuleNames → add IA_MATCHING_SURCHARGE_RULE = 'IA_MATCHING_SURCHARGE_RULE'.RuleResult → add surchargedComponentIds?: ComponentIdString[].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)
| 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 |
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.
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.
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.
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);
}
}
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 |
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).
File: src/modules/PaymentContract/RebalanceOptimizer.ts
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).
| 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 |
maxCount_HL=1; HL-X ($50, α), HL-Y ($30, β);
GS ($40, β). Base HL-X → savings $50; base HL-Y → $30 + $40 = $70.maxCount_HL=2; HL1/HL2 ($10, α), HL3 ($10, β);
GS-a ($40, α), GS-b ($5, β). Greedy-by-marginal picks HL1+HL2 → $60; optimal HL1+HL3 → $65.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.
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' },
});
}
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)
}
if (baseVRPC.paymentStatus === PaymentStatus.PENDING) {
baseVRPC.paidAmount = { amount: baseItem.finalPrice + this.sumComponentPrices(newAdditional), currency: baseVRPC.paidAmount.currency };
await this.updateVRPC(baseVRPC);
return;
}
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' },
});
}
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.
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.
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);
}
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).
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
);
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>;
}
garuda — priceDetails.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.
getCategorizedClaimItems returns empty base → rule produces zero
results (no-op by design).isIaChanged. POST_PAY uses invoice but the
insufficiency/blocking mechanism is identical to pre-pay.minCount logic; recalculated on rebalance.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.
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.
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)
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|
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)
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)
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)
VRPC Total = basePrice + Σ additional component prices + Σ surcharge prices
surcharge(c) = componentPrice(c) for each base-unmatched companion c
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)
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)
After every rebalance swap, the base VRPC ledger balances:
credits − debits + reversals = 0
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
SurchargePayment.status only follows legal transitions:
PENDING → PAID | CANCELLED
PAID → REFUNDED
(no transition out of CANCELLED or REFUNDED)