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 org.jpos.transaction.TxnId; 022 023import java.util.Optional; 024 025/** 026 * Encodes and decodes the ISO 8583 Transaction Life Cycle Identification Data 027 * (DE-021). 028 * 029 * <p>DE-021 is a 19-byte constructed field that correlates related messages 030 * across a transaction's full life cycle—authorization, financial presentment, 031 * reversal, and chargeback.</p> 032 * 033 * <h2>Wire layout</h2> 034 * <pre> 035 * Bytes Notation Sub-field 036 * 1 AN1 (ASCII) Life cycle support indicator 037 * 2–16 AN15 (ASCII) Life cycle trace identifier 038 * 17–18 N2 (BCD) Life cycle transaction sequence number 039 * 19–22 N4 (BCD) Life cycle authentication token 040 * </pre> 041 * 042 * <h2>jPOS TxnId integration</h2> 043 * <p>When jPOS originates a transaction, the trace identifier is populated from 044 * a {@link TxnId} via its base-36 {@link TxnId#toRrn()} form, right-padded with 045 * spaces to 15 characters. On inbound messages, {@link #txnId()} attempts to 046 * reverse this mapping; it returns an empty {@link Optional} when the trace 047 * identifier does not represent a {@code TxnId} (e.g. when received from an 048 * external network with its own trace ID scheme).</p> 049 * 050 * <h2>Usage — outbound (originates authorization)</h2> 051 * <pre> 052 * byte[] de21 = LifeCycleId.builder() 053 * .supportIndicator(mti) // derives '1' for 1xx, '2' for 2xx, etc. 054 * .traceId(txnId) // uses txnId.toRrn(), padded to 15 chars 055 * .build() 056 * .pack(); 057 * msg.set(new ISOBinaryField(21, de21)); 058 * </pre> 059 * 060 * <h2>Usage — outbound (financial presentment echoing prior auth)</h2> 061 * <pre> 062 * LifeCycleId authLC = LifeCycleId.unpack(authMsg.getBytes(21)); 063 * byte[] de21 = authLC.toBuilder() 064 * .sequenceNumber(seq) 065 * .authToken(tokenFromAuthResponse) 066 * .build() 067 * .pack(); 068 * </pre> 069 * 070 * <h2>Usage — inbound (from external network)</h2> 071 * <pre> 072 * LifeCycleId lc = LifeCycleId.unpack(msg.getBytes(21)); 073 * lc.txnId().ifPresent(t -> ctx.put("TXNID", t)); 074 * String rawTrace = lc.traceIdentifier(); 075 * </pre> 076 * 077 * @see <a href="https://jpos.org/doc/jPOS-CMF.pdf">jPOS CMF Specification — DE-021</a> 078 */ 079public final class LifeCycleId { 080 081 /** Wire length of the full DE-021 field in bytes. */ 082 public static final int WIRE_LENGTH = 19; 083 084 /** Maximum trace identifier length in characters. */ 085 public static final int TRACE_ID_LENGTH = 15; 086 087 private static final int SEQ_OFFSET = 16; // 1 + 15 = 16 088 private static final int AUTH_OFFSET = 17; // 16 + 1 (N2 = 2 digits BCD = 1 byte) 089 090 private final char supportIndicator; 091 private final String traceIdentifier; 092 private final int sequenceNumber; 093 private final int authToken; 094 095 private LifeCycleId(Builder b) { 096 this.supportIndicator = b.supportIndicator; 097 this.traceIdentifier = b.traceIdentifier; 098 this.sequenceNumber = b.sequenceNumber; 099 this.authToken = b.authToken; 100 } 101 102 // ── Accessors ───────────────────────────────────────────────────────────── 103 104 /** 105 * Returns the life cycle support indicator character. 106 * {@code '1'} indicates the identifier was first assigned during an 107 * authorization message; {@code '2'} during a financial presentment. 108 * 109 * @return support indicator 110 */ 111 public char supportIndicator() { 112 return supportIndicator; 113 } 114 115 /** 116 * Returns the raw 15-character life cycle trace identifier. 117 * 118 * @return trace identifier 119 */ 120 public String traceIdentifier() { 121 return traceIdentifier; 122 } 123 124 /** 125 * Attempts to parse the trace identifier as a jPOS {@link TxnId}. 126 * 127 * <p>Returns a non-empty {@link Optional} when the trimmed trace identifier 128 * was produced by {@link TxnId#toRrn()}. Returns empty for identifiers 129 * generated by other systems.</p> 130 * 131 * @return optional TxnId 132 */ 133 public Optional<TxnId> txnId() { 134 try { 135 return Optional.of(TxnId.fromRrn(traceIdentifier.trim())); 136 } catch (Exception e) { 137 return Optional.empty(); 138 } 139 } 140 141 /** 142 * Returns the life cycle transaction sequence number. 143 * Zero when not assigned. 144 * 145 * @return sequence number 146 */ 147 public int sequenceNumber() { 148 return sequenceNumber; 149 } 150 151 /** 152 * Returns the life cycle authentication token assigned by the card issuer. 153 * Zero when not yet assigned or when omitted by mutual agreement. 154 * 155 * @return authentication token 156 */ 157 public int authToken() { 158 return authToken; 159 } 160 161 // ── Pack / Unpack ───────────────────────────────────────────────────────── 162 163 /** 164 * Serializes this life cycle identifier to the DE-021 wire format. 165 * 166 * @return 19-byte wire representation 167 */ 168 public byte[] pack() { 169 byte[] buf = new byte[WIRE_LENGTH]; 170 buf[0] = (byte) supportIndicator; 171 byte[] trace = traceIdentifier.getBytes(java.nio.charset.StandardCharsets.ISO_8859_1); 172 System.arraycopy(trace, 0, buf, 1, TRACE_ID_LENGTH); 173 // Sequence number: N2 in cmf.xml = 2 BCD digits = 1 byte 174 ISOUtil.str2bcd(String.format("%02d", sequenceNumber), false, buf, SEQ_OFFSET); 175 // Auth token: N4 in cmf.xml = 4 BCD digits = 2 bytes 176 ISOUtil.str2bcd(String.format("%04d", authToken), false, buf, AUTH_OFFSET); 177 return buf; 178 } 179 180 /** 181 * Deserializes DE-021 bytes into a {@code LifeCycleId}. 182 * 183 * @param data 19-byte DE-021 field content 184 * @return parsed life cycle identifier 185 * @throws ISOException when the byte array is null, empty, or too short 186 */ 187 public static LifeCycleId unpack(byte[] data) throws ISOException { 188 if (data == null || data.length < WIRE_LENGTH) { 189 throw new ISOException("DE-021 requires exactly " + WIRE_LENGTH + " bytes, got " 190 + (data == null ? "null" : data.length)); 191 } 192 char indicator = (char) (data[0] & 0xFF); 193 String trace = new String(data, 1, TRACE_ID_LENGTH, 194 java.nio.charset.StandardCharsets.ISO_8859_1); 195 int seq = Integer.parseInt(ISOUtil.bcd2str(data, SEQ_OFFSET, 2, false)); 196 int token = Integer.parseInt(ISOUtil.bcd2str(data, AUTH_OFFSET, 4, false)); 197 return new Builder() 198 .supportIndicator(indicator) 199 .traceId(trace) 200 .sequenceNumber(seq) 201 .authToken(token) 202 .build(); 203 } 204 205 // ── Builder ─────────────────────────────────────────────────────────────── 206 207 /** 208 * Creates a new {@link Builder} pre-populated with this instance's values. 209 * Useful for constructing a financial presentment that echoes an authorization's 210 * life cycle identifier. 211 * 212 * @return builder initialized from this instance 213 */ 214 public Builder toBuilder() { 215 return new Builder() 216 .supportIndicator(supportIndicator) 217 .traceId(traceIdentifier) 218 .sequenceNumber(sequenceNumber) 219 .authToken(authToken); 220 } 221 222 /** 223 * Returns a new empty builder. 224 * 225 * @return builder 226 */ 227 public static Builder builder() { 228 return new Builder(); 229 } 230 231 /** 232 * Derives the life cycle support indicator character from an ISO 8583 MTI. 233 * Returns the first digit of the MTI's class component (position 1, 0-indexed). 234 * 235 * <p>Examples: {@code "0100"} → {@code '1'}, {@code "0200"} → {@code '2'}, 236 * {@code "0420"} → {@code '4'}.</p> 237 * 238 * @param mti 3- or 4-character ISO 8583 MTI string 239 * @return support indicator character 240 * @throws IllegalArgumentException when the MTI is null or too short 241 */ 242 public static char supportIndicatorForMTI(String mti) { 243 if (mti == null || mti.length() < 2) { 244 throw new IllegalArgumentException("MTI too short: " + mti); 245 } 246 // For a 4-char MTI (e.g. "0100"), class is digit at index 1. 247 // For a 3-char MTI used in CMF shorthand (e.g. "100"), class is digit at index 0. 248 return mti.length() >= 4 ? mti.charAt(1) : mti.charAt(0); 249 } 250 251 // ───────────────────────────────────────────────────────────────────────── 252 253 /** 254 * Builder for {@link LifeCycleId}. 255 */ 256 public static final class Builder { 257 private char supportIndicator = '0'; 258 private String traceIdentifier = padTrace(""); 259 private int sequenceNumber = 0; 260 private int authToken = 0; 261 262 private Builder() {} 263 264 /** 265 * Sets the support indicator from a literal character. 266 * 267 * @param indicator support indicator character 268 * @return this builder 269 */ 270 public Builder supportIndicator(char indicator) { 271 this.supportIndicator = indicator; 272 return this; 273 } 274 275 /** 276 * Derives the support indicator from an ISO 8583 MTI string. 277 * 278 * @param mti ISO 8583 MTI (3 or 4 characters) 279 * @return this builder 280 */ 281 public Builder supportIndicator(String mti) { 282 this.supportIndicator = supportIndicatorForMTI(mti); 283 return this; 284 } 285 286 /** 287 * Sets the trace identifier from a jPOS {@link TxnId}. 288 * Uses {@link TxnId#toRrn()}, right-padded with spaces to 15 characters. 289 * 290 * @param txnId jPOS transaction identifier 291 * @return this builder 292 */ 293 public Builder traceId(TxnId txnId) { 294 this.traceIdentifier = padTrace(txnId.toRrn()); 295 return this; 296 } 297 298 /** 299 * Sets the trace identifier from a raw string. 300 * MUST be at most 15 characters; shorter values are right-padded with spaces. 301 * 302 * @param trace trace identifier string 303 * @return this builder 304 * @throws IllegalArgumentException when the string exceeds 15 characters 305 */ 306 public Builder traceId(String trace) { 307 if (trace != null && trace.length() > TRACE_ID_LENGTH) { 308 throw new IllegalArgumentException( 309 "Trace identifier exceeds " + TRACE_ID_LENGTH + " characters: " + trace); 310 } 311 this.traceIdentifier = padTrace(trace == null ? "" : trace); 312 return this; 313 } 314 315 /** 316 * Sets the life cycle transaction sequence number. 317 * 318 * @param seq sequence number (0–9999) 319 * @return this builder 320 */ 321 public Builder sequenceNumber(int seq) { 322 this.sequenceNumber = seq; 323 return this; 324 } 325 326 /** 327 * Sets the life cycle authentication token. 328 * 329 * @param token authentication token (0–9999) 330 * @return this builder 331 */ 332 public Builder authToken(int token) { 333 this.authToken = token; 334 return this; 335 } 336 337 /** 338 * Builds the {@link LifeCycleId}. 339 * 340 * @return new LifeCycleId 341 */ 342 public LifeCycleId build() { 343 return new LifeCycleId(this); 344 } 345 346 private static String padTrace(String s) { 347 if (s.length() == TRACE_ID_LENGTH) return s; 348 return String.format("%-" + TRACE_ID_LENGTH + "s", s); 349 } 350 } 351 352 @Override 353 public String toString() { 354 return "LifeCycleId{indicator=" + supportIndicator 355 + ", trace='" + traceIdentifier.trim() + "'" 356 + ", seq=" + sequenceNumber 357 + ", token=" + authToken + "}"; 358 } 359}