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}