File size: 4,757 Bytes
e3b5f50
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
/**
 * CUSIP Check-Digit Validator and Calculation Utilities
 * 
 * Implements the standard CUSIP check-digit algorithm as detailed in
 * Chapter 43 of "Rights, Genomes, and Algorithms" and ANSI X9.6 standard:
 * - Positions 1-6: Issuer alphanumeric code
 * - Positions 7-8: Issue alphanumeric code
 * - Position 9: Check digit calculated via modified Luhn algorithm
 *   (Letters mapped to ordinals: A=10, B=11 ... Z=35; odd positions x1, even positions x2; sum digits of products; mod 10)
 */

export interface CusipValidationResult {
  isValid: boolean;
  normalizedCusip: string;
  issuerCode: string;
  issueCode: string;
  actualCheckDigit: number | null;
  expectedCheckDigit: number | null;
  errorMessage?: string;
}

/**
 * Calculates the standard CUSIP check digit for an 8-character CUSIP stem.
 * Returns null if the stem is invalid.
 */
export function calculateCusipCheckDigit(stem: string): number | null {
  if (!stem) return null;
  const cleanStem = stem.toUpperCase().replace(/[^0-9A-Z*@#]/g, '');
  if (cleanStem.length < 8) return null;

  let sum = 0;
  for (let i = 0; i < 8; i++) {
    const char = cleanStem[i];
    let val = 0;

    if (char >= '0' && char <= '9') {
      val = parseInt(char, 10);
    } else if (char >= 'A' && char <= 'Z') {
      val = char.charCodeAt(0) - 55; // 'A' -> 10, 'B' -> 11, etc.
    } else if (char === '*') {
      val = 36;
    } else if (char === '@') {
      val = 37;
    } else if (char === '#') {
      val = 38;
    } else {
      return null;
    }

    // Even index in 0-indexed loop corresponds to odd position (1st, 3rd, 5th, 7th) -> weight 1
    // Odd index in 0-indexed loop corresponds to even position (2nd, 4th, 6th, 8th) -> weight 2
    let product = (i % 2 === 1) ? val * 2 : val * 1;

    // Sum the decimal digits of the product
    while (product > 0) {
      sum += product % 10;
      product = Math.floor(product / 10);
    }
  }

  return (10 - (sum % 10)) % 10;
}

/**
 * Validates a 9-character CUSIP or 8-character stem.
 */
export function validateCusip(rawCusip: string): CusipValidationResult {
  if (!rawCusip) {
    return {
      isValid: false,
      normalizedCusip: '',
      issuerCode: '',
      issueCode: '',
      actualCheckDigit: null,
      expectedCheckDigit: null,
      errorMessage: 'CUSIP cannot be empty.'
    };
  }

  const cleaned = rawCusip.toUpperCase().replace(/[^0-9A-Z*@#]/g, '');

  if (cleaned.length < 8) {
    return {
      isValid: false,
      normalizedCusip: cleaned,
      issuerCode: cleaned.slice(0, 6),
      issueCode: cleaned.slice(6),
      actualCheckDigit: null,
      expectedCheckDigit: null,
      errorMessage: `CUSIP stem must be at least 8 characters (current: ${cleaned.length}).`
    };
  }

  const stem = cleaned.slice(0, 8);
  const expectedCheckDigit = calculateCusipCheckDigit(stem);

  if (expectedCheckDigit === null) {
    return {
      isValid: false,
      normalizedCusip: cleaned,
      issuerCode: stem.slice(0, 6),
      issueCode: stem.slice(6, 8),
      actualCheckDigit: null,
      expectedCheckDigit: null,
      errorMessage: 'Invalid characters in CUSIP stem.'
    };
  }

  if (cleaned.length === 8) {
    // 8-character stem provided: compute and propose full 9-char CUSIP
    return {
      isValid: false,
      normalizedCusip: `${stem}${expectedCheckDigit}`,
      issuerCode: stem.slice(0, 6),
      issueCode: stem.slice(6, 8),
      actualCheckDigit: null,
      expectedCheckDigit: expectedCheckDigit,
      errorMessage: `8-character stem provided. Check digit should be ${expectedCheckDigit}.`
    };
  }

  const actualCheckDigit = parseInt(cleaned[8], 10);

  if (isNaN(actualCheckDigit)) {
    return {
      isValid: false,
      normalizedCusip: cleaned.slice(0, 9),
      issuerCode: stem.slice(0, 6),
      issueCode: stem.slice(6, 8),
      actualCheckDigit: null,
      expectedCheckDigit,
      errorMessage: '9th character must be a numeric check digit (0-9).'
    };
  }

  const isValid = actualCheckDigit === expectedCheckDigit;

  return {
    isValid,
    normalizedCusip: cleaned.slice(0, 9),
    issuerCode: stem.slice(0, 6),
    issueCode: stem.slice(6, 8),
    actualCheckDigit,
    expectedCheckDigit,
    errorMessage: isValid 
      ? undefined 
      : `Check digit mismatch: got ${actualCheckDigit}, expected ${expectedCheckDigit} (Luhn mod-10).`
  };
}

/**
 * Format CUSIP with standard spacing (e.g., "917292 AA 1" or "917292-AA-1")
 */
export function formatCusipSpaced(raw: string): string {
  const clean = raw.toUpperCase().replace(/[^0-9A-Z*@#]/g, '');
  if (clean.length <= 6) return clean;
  if (clean.length <= 8) return `${clean.slice(0, 6)} ${clean.slice(6)}`;
  return `${clean.slice(0, 6)} ${clean.slice(6, 8)} ${clean.slice(8, 9)}`;
}