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.transaction; 020 021import java.io.File; 022import java.time.Instant; 023import java.time.LocalDate; 024import java.time.LocalDateTime; 025import java.time.ZoneId; 026import java.time.ZonedDateTime; 027import java.util.Objects; 028import java.util.regex.Matcher; 029import java.util.regex.Pattern; 030 031/** 032 * Distributed transaction identifier intended to be used as an ISO-8583 033 * Retrieval Reference Number (DE-037, RRN). 034 * 035 * <p>This class encodes a transaction timestamp (UTC, second precision), a node 036 * identifier, and a per-node transaction counter suffix into a single 037 * {@code long}. It supports:</p> 038 * 039 * <ul> 040 * <li>A canonical numeric form ({@link #id()}).</li> 041 * <li>A stable human-readable form ({@link #toString()}) suitable for logs and debugging.</li> 042 * <li>A compact base-36 form ({@link #toRrn()}) suitable for ISO-8583 field 37 (RRN).</li> 043 * <li>A filesystem-friendly relative path ({@link #toFile()}) that partitions by date/time.</li> 044 * </ul> 045 * 046 * <p><b>RRN length constraint and long-term viability.</b> 047 * ISO-8583 DE-037 limits the Retrieval Reference Number to 12 characters. When 048 * rendered in base 36, this implementation therefore enforces the numeric ceiling 049 * {@code "zzzzzzzzzzzz"} (base 36), exposed as {@link #MAX_VALUE}, so that 050 * {@link #toRrn()} always fits within 12 characters.</p> 051 * 052 * <p>This ceiling is purely a <i>numeric</i> bound required for DE-037 compliance; 053 * it is not derived from the {@code YYY-DDD-SSSSS-NNN-TTTTT} field layout. 054 * Nevertheless, when TxnIds are created through {@link #create(ZonedDateTime, int, long)}, 055 * the encoded timestamp remains comfortably below this bound for several centuries.</p> 056 * 057 * <p>In practice, the maximum <i>semantically valid</i> timestamp that can be 058 * encoded while still satisfying the DE-037 12-character constraint is 059 * <b>2473-12-31 23:59:59 UTC</b>. This places the effective limit more than four 060 * centuries in the future, making the scheme safe for long-term production use.</p> 061 * 062 * <p><b>Uniqueness model.</b> The identifier is composed of:</p> 063 * <ul> 064 * <li>{@code YYY}: years since 2000 (000..999).</li> 065 * <li>{@code DDD}: day of year (001..366).</li> 066 * <li>{@code SSSSS}: second of day (00000..86399).</li> 067 * <li>{@code NNN}: node id (000..999).</li> 068 * <li>{@code TTTTT}: last 5 digits of a per-node transaction counter (00000..99999).</li> 069 * </ul> 070 * 071 * <p>Collisions are prevented as long as, for a given {@code (UTC second, node)}, 072 * the {@code TTTTT} suffix is not reused.</p> 073 * 074 * <p><b>Time semantics.</b> All time components are encoded in UTC to avoid 075 * daylight-saving and timezone ambiguity. The timestamp component has second 076 * precision.</p> 077 * 078 * <p><b>Query-range semantics.</b> Because the numeric encoding is ordered by UTC time, 079 * callers can build inclusive numeric bounds for index range scans. For “between local 080 * dates” queries, this class provides DST-safe helpers that define a local day as the 081 * half-open interval {@code [startOfDay(d), startOfDay(d+1))} in the requested zone and 082 * then converts that to an inclusive UTC-second range.</p> 083 */ 084public class TxnId { 085 private long id; 086 087 /** 088 * Multiplier for year component (years since 2000). 089 */ 090 private static final long YMUL = 10000000000000000L; 091 092 /** 093 * Multiplier for day-of-year component. 094 */ 095 private static final long DMUL = 10000000000000L; 096 097 /** 098 * Multiplier for second-of-day component. 099 */ 100 private static final long SMUL = 100000000L; 101 102 /** 103 * Multiplier for node component. 104 */ 105 private static final long NMUL = 100000L; 106 107 /** 108 * Maximum allowed numeric value so that {@link #toRrn()} fits within 12 base-36 characters, 109 * i.e., {@code "zzzzzzzzzzzz"} (base 36). 110 */ 111 private static final long MAX_VALUE = Long.parseLong("zzzzzzzzzzzz", 36); 112 113 /** 114 * Pattern for the human-readable form produced by {@link #toString()}. 115 * Format: {@code YYY-DDD-SSSSS-NNN-TTTTT}. 116 */ 117 private static final Pattern PATTERN = 118 Pattern.compile("^([\\d]{3})-([\\d]{3})-([\\d]{5})-([\\d]{3})-([\\d]{5})$"); 119 120 private static final ZoneId UTC = ZoneId.of("UTC"); 121 122 /** 123 * Lowest node id used for inclusive range lower bounds. 124 */ 125 private static final int MIN_NODE = 0; 126 127 /** 128 * Highest node id used for inclusive range upper bounds. 129 */ 130 private static final int MAX_NODE = 999; 131 132 /** 133 * Lowest transaction suffix used for inclusive range lower bounds. 134 */ 135 private static final long MIN_SUFFIX = 0L; 136 137 /** 138 * Highest transaction suffix used for inclusive range upper bounds. 139 */ 140 private static final long MAX_SUFFIX = 99999L; 141 142 /** 143 * Inclusive numeric id range suitable for DB index range scans. 144 * 145 * <p>If {@link #isEmpty()} is {@code true}, the range contains no values and callers 146 * should skip querying (or deliberately query a range that returns no results).</p> 147 * 148 * @param fromInclusive inclusive lower bound. 149 * @param toInclusive inclusive upper bound. 150 */ 151 public record TxnIdRange(long fromInclusive, long toInclusive) { 152 public boolean isEmpty() { 153 return fromInclusive > toInclusive; 154 } 155 156 public static TxnIdRange empty() { 157 return new TxnIdRange(1L, 0L); 158 } 159 } 160 161 private TxnId() { 162 super(); 163 } 164 165 private TxnId(long l) { 166 this.id = l; 167 } 168 169 /** 170 * Returns the canonical numeric representation of this transaction id. 171 * 172 * <p>Note: not all numeric values are necessarily a valid structured TxnId; use {@link #parse(String)} 173 * when validation of the human-readable components is required.</p> 174 * 175 * @return the packed long value. 176 */ 177 public long id() { 178 return id; 179 } 180 181 private TxnId init(int year, int dayOfYear, int secondOfDay, int node, long transactionId) { 182 // Defensive checks: do not silently fold via modulo, because that can hide configuration errors 183 // and introduce collisions. 184 if (year < 0 || year > 999) 185 throw new IllegalArgumentException("Invalid year (years since 2000) " + year); 186 if (dayOfYear < 1 || dayOfYear > 366) 187 throw new IllegalArgumentException("Invalid dayOfYear " + dayOfYear); 188 if (secondOfDay < 0 || secondOfDay > 86399) 189 throw new IllegalArgumentException("Invalid secondOfDay " + secondOfDay); 190 if (node < 0 || node > 999) 191 throw new IllegalArgumentException("Invalid node " + node); 192 if (transactionId < 0 || transactionId > 99999) 193 throw new IllegalArgumentException("Invalid transactionId suffix " + transactionId); 194 195 long v = 196 (long) year * YMUL 197 + (long) dayOfYear * DMUL 198 + (long) secondOfDay * SMUL 199 + (long) node * NMUL 200 + (transactionId); 201 202 if (v < 0 || v > MAX_VALUE) 203 throw new IllegalArgumentException("TxnId exceeds maximum RRN value " + v); 204 205 id = v; 206 return this; 207 } 208 209 /** 210 * Returns a relative file path suitable to store contents of this transaction. 211 * 212 * <p>Path format: {@code yyyy/mm/dd/hh-mm-ss-NNN-TTTTT} where:</p> 213 * <ul> 214 * <li>{@code yyyy/mm/dd} is the UTC date derived from the encoded timestamp.</li> 215 * <li>{@code hh-mm-ss} is the UTC time (second precision).</li> 216 * <li>{@code NNN} is the node id (000..999).</li> 217 * <li>{@code TTTTT} is the transaction suffix (00000..99999).</li> 218 * </ul> 219 * 220 * @return a {@link File} with the above relative path. 221 */ 222 public File toFile() { 223 long l = id; 224 225 int yy = (int) (l / YMUL); 226 l -= (long) yy * YMUL; 227 228 int dd = (int) (l / DMUL); 229 l -= (long) dd * DMUL; 230 231 int sod = (int) (l / SMUL); 232 l -= (long) sod * SMUL; 233 234 int node = (int) (l / NMUL); 235 l -= (long) node * NMUL; 236 237 int hh = sod / 3600; 238 int mm = (sod - 3600 * hh) / 60; 239 int ss = sod % 60; 240 241 ZonedDateTime dt = ZonedDateTime.of(2000 + yy, 1, 1, 0, 0, 0, 0, UTC) 242 .plusDays(dd - 1L) 243 .plusHours(hh) 244 .plusMinutes(mm) 245 .plusSeconds(ss); 246 247 return new File( 248 String.format("%04d/%02d/%02d/%02d-%02d-%02d-%03d-%05d", 249 dt.getYear(), 250 dt.getMonthValue(), 251 dt.getDayOfMonth(), 252 dt.getHour(), 253 dt.getMinute(), 254 dt.getSecond(), 255 node, 256 l 257 ) 258 ); 259 } 260 261 /** 262 * Returns the human-readable form: {@code YYY-DDD-SSSSS-NNN-TTTTT}. 263 * 264 * <p>Where:</p> 265 * <ul> 266 * <li>{@code YYY}: years since 2000 (000..999).</li> 267 * <li>{@code DDD}: day of year (001..366).</li> 268 * <li>{@code SSSSS}: second of day (00000..86399).</li> 269 * <li>{@code NNN}: node id (000..999).</li> 270 * <li>{@code TTTTT}: transaction suffix (00000..99999).</li> 271 * </ul> 272 * 273 * @return the formatted TxnId string. 274 */ 275 @Override 276 public String toString() { 277 long l = id; 278 279 int yy = (int) (l / YMUL); 280 l -= (long) yy * YMUL; 281 282 int dd = (int) (l / DMUL); 283 l -= (long) dd * DMUL; 284 285 int sod = (int) (l / SMUL); 286 l -= (long) sod * SMUL; 287 288 int node = (int) (l / NMUL); 289 l -= (long) node * NMUL; 290 291 return String.format("%03d-%03d-%05d-%03d-%05d", yy, dd, sod, node, l); 292 } 293 294 /** 295 * Returns a compact base-36 rendering of {@link #id()} suitable for ISO-8583 DE-037 (RRN). 296 * 297 * <p>The numeric value is constrained to {@link #MAX_VALUE} so that the base-36 string fits within 298 * 12 characters.</p> 299 * 300 * @return base-36 string representation of the TxnId. 301 */ 302 public String toRrn() { 303 return Long.toString(id, 36); 304 } 305 306 @Override 307 public boolean equals(Object o) { 308 if (this == o) return true; 309 if (o == null || getClass() != o.getClass()) return false; 310 TxnId txnId = (TxnId) o; 311 return id == txnId.id; 312 } 313 314 @Override 315 public int hashCode() { 316 return Objects.hash(id); 317 } 318 319 /** 320 * Creates a new {@code TxnId} from a timestamp, node, and transaction suffix. 321 * 322 * <p>The timestamp is converted to UTC using {@link ZonedDateTime#withZoneSameInstant(ZoneId)} and then 323 * encoded at second precision.</p> 324 * 325 * @param zonedDateTime transaction timestamp. 326 * @param node node id (0..999). Values outside this range are wrapped using modulo 1000. 327 * @param transactionId per-node transaction suffix (0..99999). Values outside this range are wrapped 328 * using modulo 100000. 329 * @return newly created TxnId. 330 */ 331 public static TxnId create(ZonedDateTime zonedDateTime, int node, long transactionId) { 332 TxnId id = new TxnId(); 333 ZonedDateTime utcTime = zonedDateTime.withZoneSameInstant(UTC); 334 return id.init( 335 utcTime.getYear() - 2000, 336 utcTime.getDayOfYear(), 337 utcTime.toLocalTime().toSecondOfDay(), 338 Math.floorMod(node, 1000), 339 Math.floorMod(transactionId, 100000L) 340 ); 341 } 342 343 /** 344 * Creates a new {@code TxnId} from an {@link Instant} (assumed UTC), node, and transaction suffix. 345 * 346 * @param instant transaction timestamp in UTC. 347 * @param node node id (0..999). 348 * @param transactionId per-node transaction suffix (0..99999). 349 * @return newly created TxnId. 350 */ 351 public static TxnId create(Instant instant, int node, long transactionId) { 352 return create(instant.atZone(UTC), node, transactionId); 353 } 354 355 /** 356 * Parses a {@code TxnId} from its human-readable form {@code YYY-DDD-SSSSS-NNN-TTTTT}. 357 * 358 * @param idString TxnId in {@code YYY-DDD-SSSSS-NNN-TTTTT} format (as produced by {@link #toString()}). 359 * @return newly created TxnId. 360 * @throws IllegalArgumentException if {@code idString} is invalid or out of range. 361 */ 362 public static TxnId parse(String idString) { 363 Matcher matcher = PATTERN.matcher(idString); 364 if (!matcher.matches()) 365 throw new IllegalArgumentException("Invalid idString '" + idString + "'"); 366 367 return new TxnId().init( 368 Integer.parseInt(matcher.group(1)), 369 Integer.parseInt(matcher.group(2)), 370 Integer.parseInt(matcher.group(3)), 371 Integer.parseInt(matcher.group(4)), 372 Long.parseLong(matcher.group(5)) 373 ); 374 } 375 376 /** 377 * Parses a {@code TxnId} from its canonical numeric value. 378 * 379 * <p>This validates only the numeric bound required for DE-037 usage (see {@link #MAX_VALUE}). It does not 380 * validate that each encoded component is within expected ranges.</p> 381 * 382 * @param id numeric value. 383 * @return newly created TxnId. 384 * @throws IllegalArgumentException if the value is negative or exceeds {@link #MAX_VALUE}. 385 */ 386 public static TxnId parse(long id) { 387 if (id < 0 || id > MAX_VALUE) 388 throw new IllegalArgumentException("Invalid id " + id); 389 return new TxnId(id); 390 } 391 392 /** 393 * Parses a {@code TxnId} from an ISO-8583 DE-037 Retrieval Reference Number (RRN) in base 36. 394 * 395 * @param rrn base-36 value (must decode to a non-negative number not exceeding {@link #MAX_VALUE}). 396 * @return newly created TxnId. 397 * @throws IllegalArgumentException if {@code rrn} is invalid or out of range. 398 */ 399 public static TxnId fromRrn(String rrn) { 400 long id; 401 try { 402 id = Long.parseLong(rrn, 36); 403 } catch (RuntimeException e) { 404 throw new IllegalArgumentException("Invalid rrn " + rrn, e); 405 } 406 407 if (id < 0 || id > MAX_VALUE) 408 throw new IllegalArgumentException("Invalid rrn " + rrn); 409 410 return new TxnId(id); 411 } 412 413 /** 414 * Computes the lowest possible TxnId numeric value for the given UTC second, 415 * suitable for an inclusive range lower bound. 416 * 417 * <p>This uses {@code node=000} and {@code suffix=00000}.</p> 418 * 419 * @param instantUtc a timestamp whose {@link Instant#getEpochSecond()} is used. 420 * @return inclusive lower bound id. 421 */ 422 public static long lowerBoundId(Instant instantUtc) { 423 Objects.requireNonNull(instantUtc, "instantUtc"); 424 Instant t = Instant.ofEpochSecond(instantUtc.getEpochSecond()); 425 return create(t, MIN_NODE, MIN_SUFFIX).id(); 426 } 427 428 /** 429 * Computes the highest possible TxnId numeric value for the given UTC second, 430 * suitable for an inclusive range upper bound. 431 * 432 * <p>This uses {@code node=999} and {@code suffix=99999}.</p> 433 * 434 * @param instantUtc a timestamp whose {@link Instant#getEpochSecond()} is used. 435 * @return inclusive upper bound id. 436 */ 437 public static long upperBoundId(Instant instantUtc) { 438 Objects.requireNonNull(instantUtc, "instantUtc"); 439 Instant t = Instant.ofEpochSecond(instantUtc.getEpochSecond()); 440 return create(t, MAX_NODE, MAX_SUFFIX).id(); 441 } 442 443 /** 444 * Computes an inclusive numeric id range for the given UTC instant range. 445 * 446 * <p>Both ends are treated as inclusive at second precision. Any sub-second 447 * component is ignored.</p> 448 * 449 * @param fromUtc inclusive lower endpoint (UTC). 450 * @param toUtc inclusive upper endpoint (UTC). 451 * @return inclusive numeric id range; may be empty. 452 */ 453 public static TxnIdRange idRange(Instant fromUtc, Instant toUtc) { 454 Objects.requireNonNull(fromUtc, "fromUtc"); 455 Objects.requireNonNull(toUtc, "toUtc"); 456 457 long fromSec = fromUtc.getEpochSecond(); 458 long toSec = toUtc.getEpochSecond(); 459 if (fromSec > toSec) 460 return TxnIdRange.empty(); 461 462 long fromId = lowerBoundId(Instant.ofEpochSecond(fromSec)); 463 long toId = upperBoundId(Instant.ofEpochSecond(toSec)); 464 return new TxnIdRange(fromId, toId); 465 } 466 467 /** 468 * Computes an inclusive numeric id range for transactions between the given local dates, 469 * inclusive on both ends, in the provided time zone. 470 * 471 * <p>DST-safe strategy: define each local day as the half-open interval 472 * {@code [startOfDay(d), startOfDay(d+1))} in {@code zone}. This avoids constructing 473 * local “end of day” timestamps (which can be ambiguous on overlap days). 474 * The resulting UTC range is then made inclusive at second precision by subtracting 475 * one second from the exclusive end.</p> 476 * 477 * @param fromLocalDate inclusive start date in {@code zone}. 478 * @param toLocalDate inclusive end date in {@code zone}. 479 * @param zone time zone for interpreting local dates. 480 * @return inclusive numeric id range; may be empty. 481 */ 482 public static TxnIdRange idRange(LocalDate fromLocalDate, LocalDate toLocalDate, ZoneId zone) { 483 Objects.requireNonNull(fromLocalDate, "fromLocalDate"); 484 Objects.requireNonNull(toLocalDate, "toLocalDate"); 485 Objects.requireNonNull(zone, "zone"); 486 487 if (fromLocalDate.isAfter(toLocalDate)) 488 return TxnIdRange.empty(); 489 490 ZonedDateTime fromStart = fromLocalDate.atStartOfDay(zone); 491 ZonedDateTime toExclusiveStart = toLocalDate.plusDays(1L).atStartOfDay(zone); 492 493 long fromSec = fromStart.toInstant().getEpochSecond(); 494 long toExclusiveSec = toExclusiveStart.toInstant().getEpochSecond(); 495 496 // If the exclusive end is not strictly after the start, the interval is empty. 497 if (toExclusiveSec <= fromSec) 498 return TxnIdRange.empty(); 499 500 long toInclusiveSec = toExclusiveSec - 1L; 501 502 return idRange(Instant.ofEpochSecond(fromSec), Instant.ofEpochSecond(toInclusiveSec)); 503 } 504 505 /** 506 * Inclusive lower bound id for the start of the given local day in {@code zone}. 507 * 508 * @param localDate local date. 509 * @param zone zone in which the local day is defined. 510 * @return inclusive lower bound id. 511 */ 512 public static long lowerBoundId(LocalDate localDate, ZoneId zone) { 513 Objects.requireNonNull(localDate, "localDate"); 514 Objects.requireNonNull(zone, "zone"); 515 return lowerBoundId(localDate.atStartOfDay(zone).toInstant()); 516 } 517 518 /** 519 * Inclusive upper bound id for the end of the given local day in {@code zone}. 520 * 521 * <p>DST-safe: computed as one second before {@code startOfDay(localDate+1)} in {@code zone}.</p> 522 * 523 * @param localDate local date. 524 * @param zone zone in which the local day is defined. 525 * @return inclusive upper bound id. 526 */ 527 public static long upperBoundId(LocalDate localDate, ZoneId zone) { 528 Objects.requireNonNull(localDate, "localDate"); 529 Objects.requireNonNull(zone, "zone"); 530 531 ZonedDateTime start = localDate.atStartOfDay(zone); 532 ZonedDateTime nextStart = localDate.plusDays(1L).atStartOfDay(zone); 533 534 long startSec = start.toInstant().getEpochSecond(); 535 long nextStartSec = nextStart.toInstant().getEpochSecond(); 536 if (nextStartSec <= startSec) 537 return 0L; // empty day interval (defensive) 538 539 return upperBoundId(Instant.ofEpochSecond(nextStartSec - 1L)); 540 } 541 542 /** 543 * Inclusive range for local date-times in {@code zone}. 544 * 545 * <p>Note: this method interprets {@code fromLocalDateTime} and {@code toLocalDateTime} as local wall-clock 546 * times in {@code zone}. For DST gaps/overlaps, {@link ZonedDateTime#of(LocalDateTime, ZoneId)} applies the 547 * zone rules. If you need explicit overlap resolution (earlier vs later offset), pass {@link ZonedDateTime} 548 * values instead and use {@link #idRange(Instant, Instant)} or {@link #idRange(ZonedDateTime, ZonedDateTime)}.</p> 549 * 550 * @param fromLocalDateTime inclusive start time in {@code zone}. 551 * @param toLocalDateTime inclusive end time in {@code zone}. 552 * @param zone zone for interpreting local date-times. 553 * @return inclusive numeric id range; may be empty. 554 */ 555 public static TxnIdRange idRange(LocalDateTime fromLocalDateTime, LocalDateTime toLocalDateTime, ZoneId zone) { 556 Objects.requireNonNull(fromLocalDateTime, "fromLocalDateTime"); 557 Objects.requireNonNull(toLocalDateTime, "toLocalDateTime"); 558 Objects.requireNonNull(zone, "zone"); 559 return idRange(fromLocalDateTime.atZone(zone).toInstant(), toLocalDateTime.atZone(zone).toInstant()); 560 } 561 562 /** 563 * Inclusive range for zoned date-times. 564 * 565 * @param from inclusive start time. 566 * @param to inclusive end time. 567 * @return inclusive numeric id range; may be empty. 568 */ 569 public static TxnIdRange idRange(ZonedDateTime from, ZonedDateTime to) { 570 Objects.requireNonNull(from, "from"); 571 Objects.requireNonNull(to, "to"); 572 return idRange(from.toInstant(), to.toInstant()); 573 } 574}