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 /** 153 * Indicates whether this range covers no values. 154 * 155 * @return {@code true} when {@code fromInclusive > toInclusive} 156 */ 157 public boolean isEmpty() { 158 return fromInclusive > toInclusive; 159 } 160 161 /** 162 * Returns a canonical empty range (1, 0). 163 * 164 * @return an empty {@link TxnIdRange} 165 */ 166 public static TxnIdRange empty() { 167 return new TxnIdRange(1L, 0L); 168 } 169 } 170 171 private TxnId() { 172 super(); 173 } 174 175 private TxnId(long l) { 176 this.id = l; 177 } 178 179 /** 180 * Returns the canonical numeric representation of this transaction id. 181 * 182 * <p>Note: not all numeric values are necessarily a valid structured TxnId; use {@link #parse(String)} 183 * when validation of the human-readable components is required.</p> 184 * 185 * @return the packed long value. 186 */ 187 public long id() { 188 return id; 189 } 190 191 private TxnId init(int year, int dayOfYear, int secondOfDay, int node, long transactionId) { 192 // Defensive checks: do not silently fold via modulo, because that can hide configuration errors 193 // and introduce collisions. 194 if (year < 0 || year > 999) 195 throw new IllegalArgumentException("Invalid year (years since 2000) " + year); 196 if (dayOfYear < 1 || dayOfYear > 366) 197 throw new IllegalArgumentException("Invalid dayOfYear " + dayOfYear); 198 if (secondOfDay < 0 || secondOfDay > 86399) 199 throw new IllegalArgumentException("Invalid secondOfDay " + secondOfDay); 200 if (node < 0 || node > 999) 201 throw new IllegalArgumentException("Invalid node " + node); 202 if (transactionId < 0 || transactionId > 99999) 203 throw new IllegalArgumentException("Invalid transactionId suffix " + transactionId); 204 205 long v = 206 (long) year * YMUL 207 + (long) dayOfYear * DMUL 208 + (long) secondOfDay * SMUL 209 + (long) node * NMUL 210 + (transactionId); 211 212 if (v < 0 || v > MAX_VALUE) 213 throw new IllegalArgumentException("TxnId exceeds maximum RRN value " + v); 214 215 id = v; 216 return this; 217 } 218 219 /** 220 * Returns a relative file path suitable to store contents of this transaction. 221 * 222 * <p>Path format: {@code yyyy/mm/dd/hh-mm-ss-NNN-TTTTT} where:</p> 223 * <ul> 224 * <li>{@code yyyy/mm/dd} is the UTC date derived from the encoded timestamp.</li> 225 * <li>{@code hh-mm-ss} is the UTC time (second precision).</li> 226 * <li>{@code NNN} is the node id (000..999).</li> 227 * <li>{@code TTTTT} is the transaction suffix (00000..99999).</li> 228 * </ul> 229 * 230 * @return a {@link File} with the above relative path. 231 */ 232 public File toFile() { 233 long l = id; 234 235 int yy = (int) (l / YMUL); 236 l -= (long) yy * YMUL; 237 238 int dd = (int) (l / DMUL); 239 l -= (long) dd * DMUL; 240 241 int sod = (int) (l / SMUL); 242 l -= (long) sod * SMUL; 243 244 int node = (int) (l / NMUL); 245 l -= (long) node * NMUL; 246 247 int hh = sod / 3600; 248 int mm = (sod - 3600 * hh) / 60; 249 int ss = sod % 60; 250 251 ZonedDateTime dt = ZonedDateTime.of(2000 + yy, 1, 1, 0, 0, 0, 0, UTC) 252 .plusDays(dd - 1L) 253 .plusHours(hh) 254 .plusMinutes(mm) 255 .plusSeconds(ss); 256 257 return new File( 258 String.format("%04d/%02d/%02d/%02d-%02d-%02d-%03d-%05d", 259 dt.getYear(), 260 dt.getMonthValue(), 261 dt.getDayOfMonth(), 262 dt.getHour(), 263 dt.getMinute(), 264 dt.getSecond(), 265 node, 266 l 267 ) 268 ); 269 } 270 271 /** 272 * Returns the human-readable form: {@code YYY-DDD-SSSSS-NNN-TTTTT}. 273 * 274 * <p>Where:</p> 275 * <ul> 276 * <li>{@code YYY}: years since 2000 (000..999).</li> 277 * <li>{@code DDD}: day of year (001..366).</li> 278 * <li>{@code SSSSS}: second of day (00000..86399).</li> 279 * <li>{@code NNN}: node id (000..999).</li> 280 * <li>{@code TTTTT}: transaction suffix (00000..99999).</li> 281 * </ul> 282 * 283 * @return the formatted TxnId string. 284 */ 285 @Override 286 public String toString() { 287 long l = id; 288 289 int yy = (int) (l / YMUL); 290 l -= (long) yy * YMUL; 291 292 int dd = (int) (l / DMUL); 293 l -= (long) dd * DMUL; 294 295 int sod = (int) (l / SMUL); 296 l -= (long) sod * SMUL; 297 298 int node = (int) (l / NMUL); 299 l -= (long) node * NMUL; 300 301 return String.format("%03d-%03d-%05d-%03d-%05d", yy, dd, sod, node, l); 302 } 303 304 /** 305 * Returns a compact base-36 rendering of {@link #id()} suitable for ISO-8583 DE-037 (RRN). 306 * 307 * <p>The numeric value is constrained to {@link #MAX_VALUE} so that the base-36 string fits within 308 * 12 characters.</p> 309 * 310 * @return base-36 string representation of the TxnId. 311 */ 312 public String toRrn() { 313 return Long.toString(id, 36); 314 } 315 316 @Override 317 public boolean equals(Object o) { 318 if (this == o) return true; 319 if (o == null || getClass() != o.getClass()) return false; 320 TxnId txnId = (TxnId) o; 321 return id == txnId.id; 322 } 323 324 @Override 325 public int hashCode() { 326 return Objects.hash(id); 327 } 328 329 /** 330 * Creates a new {@code TxnId} from a timestamp, node, and transaction suffix. 331 * 332 * <p>The timestamp is converted to UTC using {@link ZonedDateTime#withZoneSameInstant(ZoneId)} and then 333 * encoded at second precision.</p> 334 * 335 * @param zonedDateTime transaction timestamp. 336 * @param node node id (0..999). Values outside this range are wrapped using modulo 1000. 337 * @param transactionId per-node transaction suffix (0..99999). Values outside this range are wrapped 338 * using modulo 100000. 339 * @return newly created TxnId. 340 */ 341 public static TxnId create(ZonedDateTime zonedDateTime, int node, long transactionId) { 342 TxnId id = new TxnId(); 343 ZonedDateTime utcTime = zonedDateTime.withZoneSameInstant(UTC); 344 return id.init( 345 utcTime.getYear() - 2000, 346 utcTime.getDayOfYear(), 347 utcTime.toLocalTime().toSecondOfDay(), 348 Math.floorMod(node, 1000), 349 Math.floorMod(transactionId, 100000L) 350 ); 351 } 352 353 /** 354 * Creates a new {@code TxnId} from an {@link Instant} (assumed UTC), node, and transaction suffix. 355 * 356 * @param instant transaction timestamp in UTC. 357 * @param node node id (0..999). 358 * @param transactionId per-node transaction suffix (0..99999). 359 * @return newly created TxnId. 360 */ 361 public static TxnId create(Instant instant, int node, long transactionId) { 362 return create(instant.atZone(UTC), node, transactionId); 363 } 364 365 /** 366 * Parses a {@code TxnId} from its human-readable form {@code YYY-DDD-SSSSS-NNN-TTTTT}. 367 * 368 * @param idString TxnId in {@code YYY-DDD-SSSSS-NNN-TTTTT} format (as produced by {@link #toString()}). 369 * @return newly created TxnId. 370 * @throws IllegalArgumentException if {@code idString} is invalid or out of range. 371 */ 372 public static TxnId parse(String idString) { 373 Matcher matcher = PATTERN.matcher(idString); 374 if (!matcher.matches()) 375 throw new IllegalArgumentException("Invalid idString '" + idString + "'"); 376 377 return new TxnId().init( 378 Integer.parseInt(matcher.group(1)), 379 Integer.parseInt(matcher.group(2)), 380 Integer.parseInt(matcher.group(3)), 381 Integer.parseInt(matcher.group(4)), 382 Long.parseLong(matcher.group(5)) 383 ); 384 } 385 386 /** 387 * Parses a {@code TxnId} from its canonical numeric value. 388 * 389 * <p>This validates only the numeric bound required for DE-037 usage (see {@link #MAX_VALUE}). It does not 390 * validate that each encoded component is within expected ranges.</p> 391 * 392 * @param id numeric value. 393 * @return newly created TxnId. 394 * @throws IllegalArgumentException if the value is negative or exceeds {@link #MAX_VALUE}. 395 */ 396 public static TxnId parse(long id) { 397 if (id < 0 || id > MAX_VALUE) 398 throw new IllegalArgumentException("Invalid id " + id); 399 return new TxnId(id); 400 } 401 402 /** 403 * Parses a {@code TxnId} from an ISO-8583 DE-037 Retrieval Reference Number (RRN) in base 36. 404 * 405 * @param rrn base-36 value (must decode to a non-negative number not exceeding {@link #MAX_VALUE}). 406 * @return newly created TxnId. 407 * @throws IllegalArgumentException if {@code rrn} is invalid or out of range. 408 */ 409 public static TxnId fromRrn(String rrn) { 410 long id; 411 try { 412 id = Long.parseLong(rrn, 36); 413 } catch (RuntimeException e) { 414 throw new IllegalArgumentException("Invalid rrn " + rrn, e); 415 } 416 417 if (id < 0 || id > MAX_VALUE) 418 throw new IllegalArgumentException("Invalid rrn " + rrn); 419 420 return new TxnId(id); 421 } 422 423 /** 424 * Computes the lowest possible TxnId numeric value for the given UTC second, 425 * suitable for an inclusive range lower bound. 426 * 427 * <p>This uses {@code node=000} and {@code suffix=00000}.</p> 428 * 429 * @param instantUtc a timestamp whose {@link Instant#getEpochSecond()} is used. 430 * @return inclusive lower bound id. 431 */ 432 public static long lowerBoundId(Instant instantUtc) { 433 Objects.requireNonNull(instantUtc, "instantUtc"); 434 Instant t = Instant.ofEpochSecond(instantUtc.getEpochSecond()); 435 return create(t, MIN_NODE, MIN_SUFFIX).id(); 436 } 437 438 /** 439 * Computes the highest possible TxnId numeric value for the given UTC second, 440 * suitable for an inclusive range upper bound. 441 * 442 * <p>This uses {@code node=999} and {@code suffix=99999}.</p> 443 * 444 * @param instantUtc a timestamp whose {@link Instant#getEpochSecond()} is used. 445 * @return inclusive upper bound id. 446 */ 447 public static long upperBoundId(Instant instantUtc) { 448 Objects.requireNonNull(instantUtc, "instantUtc"); 449 Instant t = Instant.ofEpochSecond(instantUtc.getEpochSecond()); 450 return create(t, MAX_NODE, MAX_SUFFIX).id(); 451 } 452 453 /** 454 * Computes an inclusive numeric id range for the given UTC instant range. 455 * 456 * <p>Both ends are treated as inclusive at second precision. Any sub-second 457 * component is ignored.</p> 458 * 459 * @param fromUtc inclusive lower endpoint (UTC). 460 * @param toUtc inclusive upper endpoint (UTC). 461 * @return inclusive numeric id range; may be empty. 462 */ 463 public static TxnIdRange idRange(Instant fromUtc, Instant toUtc) { 464 Objects.requireNonNull(fromUtc, "fromUtc"); 465 Objects.requireNonNull(toUtc, "toUtc"); 466 467 long fromSec = fromUtc.getEpochSecond(); 468 long toSec = toUtc.getEpochSecond(); 469 if (fromSec > toSec) 470 return TxnIdRange.empty(); 471 472 long fromId = lowerBoundId(Instant.ofEpochSecond(fromSec)); 473 long toId = upperBoundId(Instant.ofEpochSecond(toSec)); 474 return new TxnIdRange(fromId, toId); 475 } 476 477 /** 478 * Computes an inclusive numeric id range for transactions between the given local dates, 479 * inclusive on both ends, in the provided time zone. 480 * 481 * <p>DST-safe strategy: define each local day as the half-open interval 482 * {@code [startOfDay(d), startOfDay(d+1))} in {@code zone}. This avoids constructing 483 * local “end of day” timestamps (which can be ambiguous on overlap days). 484 * The resulting UTC range is then made inclusive at second precision by subtracting 485 * one second from the exclusive end.</p> 486 * 487 * @param fromLocalDate inclusive start date in {@code zone}. 488 * @param toLocalDate inclusive end date in {@code zone}. 489 * @param zone time zone for interpreting local dates. 490 * @return inclusive numeric id range; may be empty. 491 */ 492 public static TxnIdRange idRange(LocalDate fromLocalDate, LocalDate toLocalDate, ZoneId zone) { 493 Objects.requireNonNull(fromLocalDate, "fromLocalDate"); 494 Objects.requireNonNull(toLocalDate, "toLocalDate"); 495 Objects.requireNonNull(zone, "zone"); 496 497 if (fromLocalDate.isAfter(toLocalDate)) 498 return TxnIdRange.empty(); 499 500 ZonedDateTime fromStart = fromLocalDate.atStartOfDay(zone); 501 ZonedDateTime toExclusiveStart = toLocalDate.plusDays(1L).atStartOfDay(zone); 502 503 long fromSec = fromStart.toInstant().getEpochSecond(); 504 long toExclusiveSec = toExclusiveStart.toInstant().getEpochSecond(); 505 506 // If the exclusive end is not strictly after the start, the interval is empty. 507 if (toExclusiveSec <= fromSec) 508 return TxnIdRange.empty(); 509 510 long toInclusiveSec = toExclusiveSec - 1L; 511 512 return idRange(Instant.ofEpochSecond(fromSec), Instant.ofEpochSecond(toInclusiveSec)); 513 } 514 515 /** 516 * Inclusive lower bound id for the start of the given local day in {@code zone}. 517 * 518 * @param localDate local date. 519 * @param zone zone in which the local day is defined. 520 * @return inclusive lower bound id. 521 */ 522 public static long lowerBoundId(LocalDate localDate, ZoneId zone) { 523 Objects.requireNonNull(localDate, "localDate"); 524 Objects.requireNonNull(zone, "zone"); 525 return lowerBoundId(localDate.atStartOfDay(zone).toInstant()); 526 } 527 528 /** 529 * Inclusive upper bound id for the end of the given local day in {@code zone}. 530 * 531 * <p>DST-safe: computed as one second before {@code startOfDay(localDate+1)} in {@code zone}.</p> 532 * 533 * @param localDate local date. 534 * @param zone zone in which the local day is defined. 535 * @return inclusive upper bound id. 536 */ 537 public static long upperBoundId(LocalDate localDate, ZoneId zone) { 538 Objects.requireNonNull(localDate, "localDate"); 539 Objects.requireNonNull(zone, "zone"); 540 541 ZonedDateTime start = localDate.atStartOfDay(zone); 542 ZonedDateTime nextStart = localDate.plusDays(1L).atStartOfDay(zone); 543 544 long startSec = start.toInstant().getEpochSecond(); 545 long nextStartSec = nextStart.toInstant().getEpochSecond(); 546 if (nextStartSec <= startSec) 547 return 0L; // empty day interval (defensive) 548 549 return upperBoundId(Instant.ofEpochSecond(nextStartSec - 1L)); 550 } 551 552 /** 553 * Inclusive range for local date-times in {@code zone}. 554 * 555 * <p>Note: this method interprets {@code fromLocalDateTime} and {@code toLocalDateTime} as local wall-clock 556 * times in {@code zone}. For DST gaps/overlaps, {@link ZonedDateTime#of(LocalDateTime, ZoneId)} applies the 557 * zone rules. If you need explicit overlap resolution (earlier vs later offset), pass {@link ZonedDateTime} 558 * values instead and use {@link #idRange(Instant, Instant)} or {@link #idRange(ZonedDateTime, ZonedDateTime)}.</p> 559 * 560 * @param fromLocalDateTime inclusive start time in {@code zone}. 561 * @param toLocalDateTime inclusive end time in {@code zone}. 562 * @param zone zone for interpreting local date-times. 563 * @return inclusive numeric id range; may be empty. 564 */ 565 public static TxnIdRange idRange(LocalDateTime fromLocalDateTime, LocalDateTime toLocalDateTime, ZoneId zone) { 566 Objects.requireNonNull(fromLocalDateTime, "fromLocalDateTime"); 567 Objects.requireNonNull(toLocalDateTime, "toLocalDateTime"); 568 Objects.requireNonNull(zone, "zone"); 569 return idRange(fromLocalDateTime.atZone(zone).toInstant(), toLocalDateTime.atZone(zone).toInstant()); 570 } 571 572 /** 573 * Inclusive range for zoned date-times. 574 * 575 * @param from inclusive start time. 576 * @param to inclusive end time. 577 * @return inclusive numeric id range; may be empty. 578 */ 579 public static TxnIdRange idRange(ZonedDateTime from, ZonedDateTime to) { 580 Objects.requireNonNull(from, "from"); 581 Objects.requireNonNull(to, "to"); 582 return idRange(from.toInstant(), to.toInstant()); 583 } 584}