001/* 002 * jPOS Project [http://jpos.org] 003 * Copyright (C) 2000-2026 jPOS Software SRL 004 * 005 * This program is free software: you can redistribute it and/or modify 006 * it under the terms of the GNU Affero General Public License as 007 * published by the Free Software Foundation, either version 3 of the 008 * License, or (at your option) any later version. 009 * 010 * This program is distributed in the hope that it will be useful, 011 * but WITHOUT ANY WARRANTY; without even the implied warranty of 012 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 013 * GNU Affero General Public License for more details. 014 * 015 * You should have received a copy of the GNU Affero General Public License 016 * along with this program. If not, see <http://www.gnu.org/licenses/>. 017 */ 018 019package org.jpos.iso; 020 021import java.util.ArrayList; 022import java.util.Collections; 023import java.util.List; 024import java.util.Objects; 025 026/** 027 * Encodes and decodes the ISO 8583 Message Error Indicator (DE-018). 028 * 029 * <p>DE-018 carries up to ten error sets, each exactly 14 positions long. 030 * Each set identifies the type of error and the precise location within the 031 * message (data element, sub-element for constructed fields, or dataset 032 * identifier plus bit/tag for composite fields).</p> 033 * 034 * <p>Error sets are concatenated without separators. The overall field is 035 * transmitted as an {@code LLLVAR} character field.</p> 036 * 037 * <h2>Error set wire layout</h2> 038 * <pre> 039 * Pos Len Type Subfield 040 * 1–2 2 N2 (ASCII) Error severity 041 * 3–6 4 N4 (ASCII) Message error code 042 * 7–9 3 N3 (ASCII) Data element in error (001–128) 043 * 10–11 2 N2 (ASCII) Data sub-element in error (constructed DEs), else "00" 044 * 12 1 B1 (binary) Dataset identifier (composite DEs), else 0x00 045 * 13–14 2 B2 (binary) Dataset bit or TLV tag (composite DEs), else 0x0000 046 * </pre> 047 * 048 * <p>Usage:</p> 049 * <pre> 050 * MessageErrorIndicator mei = new MessageErrorIndicator() 051 * .add(FieldError.primitiveError(ErrorCode.REQUIRED_MISSING, 37)) 052 * .add(FieldError.compositeError(ErrorCode.INVALID_VALUE, 55, 0x37, 0x9F26)); 053 * 054 * msg.set(18, new String(mei.pack(), ISOUtil.CHARSET)); 055 * 056 * // or 057 * 058 * MessageErrorIndicator parsed = MessageErrorIndicator.unpack(msg.getBytes(18)); 059 * </pre> 060 * 061 * @see <a href="https://jpos.org/doc/jPOS-CMF.pdf">jPOS CMF Specification — DE-018</a> 062 */ 063public class MessageErrorIndicator { 064 065 /** Maximum number of error sets per DE-018 field. */ 066 public static final int MAX_ERROR_SETS = 10; 067 068 /** Length in bytes of one error set on the wire. */ 069 public static final int ERROR_SET_LENGTH = 14; 070 071 private final List<FieldError> errors = new ArrayList<>(); 072 073 /** 074 * Creates an empty indicator. 075 */ 076 public MessageErrorIndicator() { 077 } 078 079 /** 080 * Appends an error set. 081 * 082 * @param error error set to add 083 * @return this indicator for fluent chaining 084 * @throws IllegalStateException when the maximum of 10 error sets has been reached 085 */ 086 public MessageErrorIndicator add(FieldError error) { 087 if (errors.size() >= MAX_ERROR_SETS) { 088 throw new IllegalStateException("DE-018 may carry at most " + MAX_ERROR_SETS + " error sets"); 089 } 090 Objects.requireNonNull(error, "error cannot be null"); 091 errors.add(error); 092 return this; 093 } 094 095 /** 096 * Returns an unmodifiable view of the current error sets. 097 * 098 * @return error set list 099 */ 100 public List<FieldError> errors() { 101 return Collections.unmodifiableList(errors); 102 } 103 104 /** 105 * Indicates whether this indicator contains no error sets. 106 * 107 * @return {@code true} when empty 108 */ 109 public boolean isEmpty() { 110 return errors.isEmpty(); 111 } 112 113 /** 114 * Returns the number of error sets. 115 * 116 * @return error set count 117 */ 118 public int size() { 119 return errors.size(); 120 } 121 122 /** 123 * Serializes all error sets to the DE-018 wire format. 124 * 125 * @return packed bytes suitable for setting on DE-018 of an ISOMsg 126 */ 127 public byte[] pack() { 128 byte[] result = new byte[errors.size() * ERROR_SET_LENGTH]; 129 int offset = 0; 130 for (FieldError error : errors) { 131 byte[] set = error.pack(); 132 System.arraycopy(set, 0, result, offset, ERROR_SET_LENGTH); 133 offset += ERROR_SET_LENGTH; 134 } 135 return result; 136 } 137 138 /** 139 * Deserializes DE-018 wire bytes into a {@code MessageErrorIndicator}. 140 * 141 * @param data DE-018 field bytes (must be a multiple of 14) 142 * @return parsed indicator 143 * @throws ISOException when the byte array is malformed 144 */ 145 public static MessageErrorIndicator unpack(byte[] data) throws ISOException { 146 if (data == null || data.length == 0) { 147 return new MessageErrorIndicator(); 148 } 149 if (data.length % ERROR_SET_LENGTH != 0) { 150 throw new ISOException("DE-018 length " + data.length 151 + " is not a multiple of " + ERROR_SET_LENGTH); 152 } 153 int count = data.length / ERROR_SET_LENGTH; 154 if (count > MAX_ERROR_SETS) { 155 throw new ISOException("DE-018 contains " + count 156 + " error sets, maximum is " + MAX_ERROR_SETS); 157 } 158 MessageErrorIndicator mei = new MessageErrorIndicator(); 159 for (int i = 0; i < count; i++) { 160 int off = i * ERROR_SET_LENGTH; 161 mei.errors.add(FieldError.unpack(data, off)); 162 } 163 return mei; 164 } 165 166 // ───────────────────────────────────────────────────────────────────────── 167 168 /** 169 * Standard message error codes defined by ISO 8583:2023, Table D.15. 170 * 171 * <p>Codes 0014–3999 are reserved for ISO use; 4000–5999 for national use; 172 * 6000–9999 for private use.</p> 173 */ 174 public enum ErrorCode { 175 /** Required data element is missing. */ 176 REQUIRED_MISSING(1), 177 /** Data element length is invalid. */ 178 INVALID_LENGTH(2), 179 /** Data element contains an invalid value. */ 180 INVALID_VALUE(3), 181 /** Amount field has a format error. */ 182 AMOUNT_FORMAT(4), 183 /** Date field has a format error. */ 184 DATE_FORMAT(5), 185 /** Account identifier has a format error. */ 186 ACCOUNT_FORMAT(6), 187 /** Name field has a format error. */ 188 NAME_FORMAT(7), 189 /** Other format error. */ 190 FORMAT_OTHER(8), 191 /** Data inconsistent with POS data code. */ 192 INCONSISTENT_WITH_POS_CODE(9), 193 /** Data does not match the original request. */ 194 INCONSISTENT_WITH_ORIGINAL(10), 195 /** Other inconsistent data. */ 196 INCONSISTENT_OTHER(11), 197 /** Recurring data error. */ 198 RECURRING_DATA(12), 199 /** Customer vendor format error. */ 200 CUSTOMER_VENDOR_FORMAT(13); 201 202 private final int code; 203 204 ErrorCode(int code) { 205 this.code = code; 206 } 207 208 /** 209 * Returns the 4-digit numeric code as carried on the wire. 210 * 211 * @return numeric code 212 */ 213 public int code() { 214 return code; 215 } 216 217 /** 218 * Returns the error code as a 4-character left-zero-padded ASCII string. 219 * 220 * @return wire-format code string 221 */ 222 public String codeString() { 223 return String.format("%04d", code); 224 } 225 226 /** 227 * Resolves an {@link ErrorCode} from its numeric value. 228 * 229 * @param code numeric code 230 * @return matching constant or {@code null} when not in the ISO-defined range 231 */ 232 public static ErrorCode of(int code) { 233 for (ErrorCode ec : values()) { 234 if (ec.code == code) { 235 return ec; 236 } 237 } 238 return null; 239 } 240 } 241 242 /** 243 * Error severity carried in positions 1–2 of each error set. 244 */ 245 public enum Severity { 246 /** The message was rejected due to this error. */ 247 REJECTED(0), 248 /** The message was accepted but contains a non-critical error. */ 249 WARNING(1); 250 251 private final int value; 252 253 Severity(int value) { 254 this.value = value; 255 } 256 257 /** 258 * Returns the 2-digit numeric value as carried on the wire. 259 * 260 * @return numeric severity value 261 */ 262 public int value() { 263 return value; 264 } 265 266 /** 267 * Resolves a {@link Severity} from its numeric value. 268 * 269 * @param value 0 or 1 270 * @return matching constant, defaulting to {@link #REJECTED} for unknown values 271 */ 272 public static Severity of(int value) { 273 for (Severity s : values()) { 274 if (s.value == value) { 275 return s; 276 } 277 } 278 return REJECTED; 279 } 280 } 281 282 // ───────────────────────────────────────────────────────────────────────── 283 284 /** 285 * One error set within a {@link MessageErrorIndicator} field. 286 * 287 * <p>The wire layout is exactly 14 bytes:</p> 288 * <ul> 289 * <li>2 ASCII digits — severity</li> 290 * <li>4 ASCII digits — message error code</li> 291 * <li>3 ASCII digits — data element number (001–128)</li> 292 * <li>2 ASCII digits — sub-element (constructed DEs) or "00"</li> 293 * <li>1 binary byte — dataset identifier (composite DEs) or 0x00</li> 294 * <li>2 binary bytes — dataset bit or TLV tag (composite DEs) or 0x0000</li> 295 * </ul> 296 */ 297 public static class FieldError { 298 299 private final Severity severity; 300 private final int errorCode; 301 private final int deNumber; 302 private final int subElement; 303 private final int datasetIdentifier; 304 private final int datasetBitOrTag; 305 306 private FieldError(Severity severity, int errorCode, int deNumber, 307 int subElement, int datasetIdentifier, int datasetBitOrTag) { 308 if (deNumber < 1 || deNumber > 128) { 309 throw new IllegalArgumentException("deNumber must be 1–128"); 310 } 311 this.severity = severity; 312 this.errorCode = errorCode; 313 this.deNumber = deNumber; 314 this.subElement = subElement; 315 this.datasetIdentifier = datasetIdentifier; 316 this.datasetBitOrTag = datasetBitOrTag; 317 } 318 319 /** 320 * Creates an error set for a primitive data element. 321 * 322 * @param errorCode error code 323 * @param deNumber data element number (1–128) 324 * @return new error set 325 */ 326 public static FieldError primitiveError(ErrorCode errorCode, int deNumber) { 327 return new FieldError(Severity.REJECTED, errorCode.code(), deNumber, 0, 0, 0); 328 } 329 330 /** 331 * Creates an error set for a primitive data element with a specified severity. 332 * 333 * @param severity error severity 334 * @param errorCode error code 335 * @param deNumber data element number (1–128) 336 * @return new error set 337 */ 338 public static FieldError primitiveError(Severity severity, ErrorCode errorCode, int deNumber) { 339 return new FieldError(severity, errorCode.code(), deNumber, 0, 0, 0); 340 } 341 342 /** 343 * Creates an error set for a sub-element within a constructed data element. 344 * 345 * @param errorCode error code 346 * @param deNumber data element number (1–128) 347 * @param subElement sub-element part number (1-based) 348 * @return new error set 349 */ 350 public static FieldError constructedError(ErrorCode errorCode, int deNumber, int subElement) { 351 return new FieldError(Severity.REJECTED, errorCode.code(), deNumber, subElement, 0, 0); 352 } 353 354 /** 355 * Creates an error set for a sub-element within a composite (dataset) data element. 356 * 357 * @param errorCode error code 358 * @param deNumber data element number (1–128) 359 * @param datasetIdentifier dataset identifier byte (0x01–0xFE) 360 * @param datasetBitOrTag DBM bit number or BER-TLV tag (packed into 2 bytes big-endian) 361 * @return new error set 362 */ 363 public static FieldError compositeError(ErrorCode errorCode, int deNumber, 364 int datasetIdentifier, int datasetBitOrTag) { 365 return new FieldError(Severity.REJECTED, errorCode.code(), deNumber, 0, 366 datasetIdentifier, datasetBitOrTag); 367 } 368 369 /** 370 * Creates an error set with a raw numeric error code (for private-use or 371 * national-use codes outside the ISO-defined {@link ErrorCode} enum). 372 * 373 * @param severity error severity 374 * @param errorCode raw 4-digit error code (0001–9999) 375 * @param deNumber data element number (1–128) 376 * @return new error set 377 */ 378 public static FieldError withRawCode(Severity severity, int errorCode, int deNumber) { 379 return new FieldError(severity, errorCode, deNumber, 0, 0, 0); 380 } 381 382 /** 383 * Returns the severity of this error set. 384 * 385 * @return error severity 386 */ 387 public Severity severity() { return severity; } 388 389 /** 390 * Returns the raw 4-digit error code value carried on the wire. 391 * 392 * @return message error code numeric value 393 */ 394 public int errorCode() { return errorCode; } 395 396 /** 397 * Returns the {@link ErrorCode} matching {@link #errorCode()}, if any. 398 * 399 * @return resolved {@link ErrorCode} or {@code null} for private/national-use codes 400 */ 401 public ErrorCode errorCodeEnum() { return ErrorCode.of(errorCode); } 402 403 /** 404 * Returns the data element number flagged by this error. 405 * 406 * @return data element number in error 407 */ 408 public int deNumber() { return deNumber; } 409 410 /** 411 * Returns the sub-element number for constructed data elements. 412 * 413 * @return sub-element number (constructed DEs), 0 otherwise 414 */ 415 public int subElement() { return subElement; } 416 417 /** 418 * Returns the dataset identifier byte for composite data elements. 419 * 420 * @return dataset identifier (composite DEs), 0 otherwise 421 */ 422 public int datasetIdentifier() { return datasetIdentifier; } 423 424 /** 425 * Returns the DBM bit number or BER-TLV tag for composite data elements. 426 * 427 * @return dataset bit number or TLV tag (composite DEs), 0 otherwise 428 */ 429 public int datasetBitOrTag() { return datasetBitOrTag; } 430 431 /** 432 * Serializes this error set to exactly 14 bytes. 433 * 434 * @return 14-byte wire representation 435 */ 436 public byte[] pack() { 437 byte[] buf = new byte[ERROR_SET_LENGTH]; 438 // Severity: 2 ASCII digits 439 buf[0] = (byte) ('0' + (severity.value() / 10)); 440 buf[1] = (byte) ('0' + (severity.value() % 10)); 441 // Error code: 4 ASCII digits 442 String codeStr = String.format("%04d", errorCode); 443 buf[2] = (byte) codeStr.charAt(0); 444 buf[3] = (byte) codeStr.charAt(1); 445 buf[4] = (byte) codeStr.charAt(2); 446 buf[5] = (byte) codeStr.charAt(3); 447 // DE number: 3 ASCII digits 448 String deStr = String.format("%03d", deNumber); 449 buf[6] = (byte) deStr.charAt(0); 450 buf[7] = (byte) deStr.charAt(1); 451 buf[8] = (byte) deStr.charAt(2); 452 // Sub-element: 2 ASCII digits 453 String seStr = String.format("%02d", subElement); 454 buf[9] = (byte) seStr.charAt(0); 455 buf[10] = (byte) seStr.charAt(1); 456 // Dataset identifier: 1 binary byte 457 buf[11] = (byte) (datasetIdentifier & 0xFF); 458 // Dataset bit/tag: 2 binary bytes big-endian 459 buf[12] = (byte) ((datasetBitOrTag >> 8) & 0xFF); 460 buf[13] = (byte) (datasetBitOrTag & 0xFF); 461 return buf; 462 } 463 464 /** 465 * Deserializes one error set from 14 bytes at the given offset. 466 * 467 * @param data source buffer 468 * @param offset starting offset 469 * @return parsed error set 470 * @throws ISOException on malformed data 471 */ 472 static FieldError unpack(byte[] data, int offset) throws ISOException { 473 if (data.length - offset < ERROR_SET_LENGTH) { 474 throw new ISOException("Insufficient data for DE-018 error set at offset " + offset); 475 } 476 try { 477 int sev = Integer.parseInt(new String(data, offset, 2)); 478 int code = Integer.parseInt(new String(data, offset + 2, 4)); 479 int deNum = Integer.parseInt(new String(data, offset + 6, 3)); 480 int subEl = Integer.parseInt(new String(data, offset + 9, 2)); 481 int dsId = data[offset + 11] & 0xFF; 482 int bitOrTag = ((data[offset + 12] & 0xFF) << 8) | (data[offset + 13] & 0xFF); 483 return new FieldError(Severity.of(sev), code, deNum, subEl, dsId, bitOrTag); 484 } catch (NumberFormatException e) { 485 throw new ISOException("Malformed DE-018 error set at offset " + offset, e); 486 } 487 } 488 489 @Override 490 public String toString() { 491 ErrorCode ec = errorCodeEnum(); 492 return "FieldError{sev=" + severity 493 + ", code=" + String.format("%04d", errorCode) 494 + (ec != null ? "(" + ec.name() + ")" : "") 495 + ", de=" + String.format("%03d", deNumber) 496 + (subElement != 0 ? ", sub=" + subElement : "") 497 + (datasetIdentifier != 0 ? String.format(", dsId=0x%02X", datasetIdentifier) : "") 498 + (datasetBitOrTag != 0 ? String.format(", bitOrTag=0x%04X", datasetBitOrTag) : "") 499 + "}"; 500 } 501 } 502}