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}