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 java.text.DateFormat;
022import java.text.SimpleDateFormat;
023import java.time.Duration;
024import java.util.*;
025
026/**
027 * provides various parsing and format functions used
028 * by the ISO 8583 specs.
029 *
030 * @author apr@cs.com.uy
031 * @author Hani S. Kirollos
032 * @version $Id$
033 * @see ISOUtil
034 */
035public class ISODate {
036    private ISODate() {
037        throw new AssertionError();
038    }
039
040    /** One year in milliseconds (365 days). */
041    public static final long ONE_YEAR = 365L*86400L*1000L;
042   /**
043    * Formats a date object, using the default time zone for this host
044    *
045    * WARNING: See <a href="https://jpos.org/faq/isodate_pattern.html">important issue</a> related to date pattern.
046    *
047    * @param d date object to be formatted
048    * @param pattern to be used for formatting
049    * @return the formatted date string
050    */
051    public static String formatDate (Date d, String pattern) {
052        return formatDate(d, pattern, TimeZone.getDefault());
053    }
054    /**
055     * You should use this version of formatDate() if you want a specific 
056     * timeZone to calculate the date on.
057     *
058     * WARNING: See <a href="https://jpos.org/faq/isodate_pattern.html">important issue</a> related to date pattern.
059     *
060     * @param d date object to be formatted
061     * @param pattern to be used for formatting
062     * @param timeZone for GMT for example, use TimeZone.getTimeZone("GMT")
063     *        and for Uruguay use TimeZone.getTimeZone("GMT-03:00")
064     * @return the formatted date string
065     */
066    public static String formatDate (Date d, String pattern, TimeZone timeZone) {
067        SimpleDateFormat df =
068            (SimpleDateFormat) DateFormat.getDateTimeInstance();
069        df.setTimeZone(timeZone);
070        df.applyPattern(pattern);
071        return df.format(d);
072    }
073    /**
074     * converts a string in DD/MM/YY format to a Date object
075     * Warning: return null on invalid dates (prints Exception to console)
076     * Uses default time zone for this host
077     * @return parsed Date (or null)
078     * @param s the date string to parse
079     */
080    public static Date parse(String s) {
081        return parse(s, TimeZone.getDefault());
082    }
083    /**
084     * converts a string in DD/MM/YY format to a Date object
085     * Warning: return null on invalid dates (prints Exception to console)
086     * @param s String in DD/MM/YY recorded in timeZone
087     * @param timeZone for GMT for example, use TimeZone.getTimeZone("GMT")
088     *        and for Uruguay use TimeZone.getTimeZone("GMT-03:00")
089     * @return parsed Date (or null)
090     */
091    public static Date parse(String s, TimeZone timeZone) {
092        Date d = null;
093        SimpleDateFormat df =
094            (SimpleDateFormat) DateFormat.getDateInstance(
095                DateFormat.SHORT, Locale.UK);
096        df.setTimeZone (timeZone);
097        try {
098            d = df.parse (s);
099        } catch (java.text.ParseException e) {
100        }
101        return d;
102    }
103    /**
104     * converts a string in DD/MM/YY HH:MM:SS format to a Date object
105     * Warning: return null on invalid dates (prints Exception to console)
106     * Uses default time zone for this host
107     * @return parsed Date (or null)
108     * @param s the date string to parse
109     */
110    public static Date parseDateTime(String s) {
111        return parseDateTime(s, TimeZone.getDefault());
112    }
113    /**
114     * converts a string in DD/MM/YY HH:MM:SS format to a Date object
115     * Warning: return null on invalid dates (prints Exception to console)
116     * @param s string in DD/MM/YY HH:MM:SS format recorded in timeZone
117     * @param timeZone for GMT for example, use TimeZone.getTimeZone("GMT")
118     *        and for Uruguay use TimeZone.getTimeZone("GMT-03:00")
119     * @return parsed Date (or null)
120     */
121    public static Date parseDateTime(String s, TimeZone timeZone) {
122        Date d = null;
123        SimpleDateFormat df =
124            new SimpleDateFormat("dd/MM/yy hh:mm:ss", Locale.UK);
125
126        df.setTimeZone (timeZone);
127        try {
128            d = df.parse (s);
129        } catch (java.text.ParseException e) { }
130        return d;
131    }
132
133    /**
134     * try to find out suitable date given [YY[YY]]MMDDhhmmss format<br>
135     * (difficult thing being finding out appropiate year)
136     * @param d date formated as [YY[YY]]MMDDhhmmss, typical field 13 + field 12
137     * @return Date
138     */
139    public static Date parseISODate (String d) {
140        return parseISODate (d, System.currentTimeMillis());
141    }
142
143    /**
144     * try to find out suitable date given [YY[YY]]MMDDhhmmss format<br>
145     * (difficult thing being finding out appropiate year)
146     * @param d date formated as [YY[YY]]MMDDhhmmss, typical field 13 + field 12
147     * @param timeZone for GMT for example, use TimeZone.getTimeZone("GMT")
148     *        and for Uruguay use TimeZone.getTimeZone("GMT-03:00")
149     * @return Date
150     */
151    public static Date parseISODate (String d, TimeZone timeZone) {
152        return parseISODate (d, System.currentTimeMillis(), timeZone);
153    }
154
155    /**
156     * try to find out suitable date given [YY[YY]]MMDDhhmmss format<br>
157     * (difficult thing being finding out appropiate year)
158     * @param d date formated as [YY[YY]]MMDDhhmmss, typical field 13 + field 12
159     * @param currentTime currentTime in millis
160     * @return Date
161     */
162    public static Date parseISODate (String d, long currentTime) {
163        return parseISODate (d, currentTime, TimeZone.getDefault() );
164    }
165    /**
166     * try to find out suitable date given [YY[YY]]MMDDhhmmss format<br>
167     * (difficult thing being finding out appropiate year)
168     * @param d date formated as [YY[YY]]MMDDhhmmss, typical field 13 + field 12
169     * @param currentTime currentTime in millis
170     * @param timeZone for GMT for example, use TimeZone.getTimeZone("GMT")
171     *        and for Uruguay use TimeZone.getTimeZone("GMT-03:00")
172     * @return Date
173     */
174    public static Date parseISODate (String d, long currentTime, TimeZone timeZone) {
175        int YY = 0;
176
177        Calendar cal = new GregorianCalendar();
178        cal.setTimeZone(timeZone);
179        Date now = new Date(currentTime);
180        cal.setTime (now);
181
182        if (d.length() == 14) {
183            YY = Integer.parseInt(d.substring (0, 4));
184            d = d.substring (4);
185        }
186        else if (d.length() == 12) {
187            YY = calculateNearestFullYear(Integer.parseInt(d.substring(0, 2)), cal);
188            d = d.substring (2);
189        } 
190        int MM = Integer.parseInt(d.substring (0, 2))-1;
191        int DD = Integer.parseInt(d.substring (2, 4));
192        int hh = Integer.parseInt(d.substring (4, 6));
193        int mm = Integer.parseInt(d.substring (6, 8));
194        int ss = Integer.parseInt(d.substring (8,10));
195        
196        cal.set (Calendar.MONTH, MM);
197        cal.set (Calendar.DATE, DD);
198        cal.set (Calendar.HOUR_OF_DAY, hh);
199        cal.set (Calendar.MINUTE, mm);
200        cal.set (Calendar.SECOND, ss);
201        cal.set (Calendar.MILLISECOND, 0);
202
203        if (YY != 0) {
204            cal.set (Calendar.YEAR, YY);
205            return cal.getTime();
206        } 
207        else {
208            Date thisYear = cal.getTime();
209            cal.set (Calendar.YEAR, cal.get (Calendar.YEAR)-1);
210            Date previousYear = cal.getTime();
211            cal.set (Calendar.YEAR, cal.get (Calendar.YEAR)+2);
212            Date nextYear = cal.getTime();
213            if (Math.abs (now.getTime() - previousYear.getTime()) <
214                Math.abs (now.getTime() - thisYear.getTime())) 
215            {
216                thisYear = previousYear;
217            } else if (Math.abs (now.getTime() - thisYear.getTime()) >
218                       Math.abs (now.getTime() - nextYear.getTime()))
219            {
220                thisYear = nextYear;
221            }
222            return thisYear;
223        }
224    }
225
226    /**
227     * Formats the given date.
228     * @return date in MMddHHmmss format suitable for FIeld 7
229     * @param d the date to format
230     */
231    public static String getDateTime (Date d) {
232        return formatDate (d, "MMddHHmmss");
233    }
234        /**
235         * Formats the given date.
236         * @param d date object to be formatted
237     * @param timeZone for GMT for example, use TimeZone.getTimeZone("GMT")
238     *        and for Uruguay use TimeZone.getTimeZone("GMT-03:00")
239     * @return date in MMddHHmmss format suitable for FIeld 7
240     */
241    public static String getDateTime (Date d, TimeZone timeZone) {
242        return formatDate (d, "MMddHHmmss", timeZone);
243    }
244    /**
245     * Formats the given date.
246     * @return date in HHmmss format - suitable for field 12
247     * @param d the date to format
248     */
249    public static String getTime (Date d) {
250        return formatDate (d, "HHmmss");
251    }
252    /**
253         * Formats the given date.
254         * @param d date object to be formatted
255     * @param timeZone for GMT for example, use TimeZone.getTimeZone("GMT")
256     *        and for Uruguay use TimeZone.getTimeZone("GMT-03:00")
257     * @return date in HHmmss format - suitable for field 12
258     */
259    public static String getTime (Date d, TimeZone timeZone) {
260        return formatDate (d, "HHmmss", timeZone);
261    }
262    /**
263     * Formats the given date.
264     * @return date in MMdd format - suitable for field 13
265     * @param d the date to format
266     */
267    public static String getDate(Date d) {
268        return formatDate (d, "MMdd");
269    }
270    /**
271         * Formats the given date.
272         * @param d date object to be formatted
273     * @param timeZone for GMT for example, use TimeZone.getTimeZone("GMT")
274     *        and for Uruguay use TimeZone.getTimeZone("GMT-03:00")
275     * @return date in MMdd format - suitable for field 13
276     */
277    public static String getDate(Date d, TimeZone timeZone) {
278        return formatDate (d, "MMdd", timeZone);
279    }
280    /**
281     * Formats the given date.
282     * @return date in yyMMdd format - suitable for ANSI field 8
283     * @param d the date to format
284     */
285    public static String getANSIDate(Date d) {
286        return formatDate (d, "yyMMdd");
287    }
288    /**
289         * Formats the given date.
290         * @param d date object to be formatted
291     * @param timeZone for GMT for example, use TimeZone.getTimeZone("GMT")
292     *        and for Uruguay use TimeZone.getTimeZone("GMT-03:00")
293     * @return date in yyMMdd format - suitable for ANSI field 8
294     */
295    public static String getANSIDate(Date d, TimeZone timeZone) {
296        return formatDate (d, "yyMMdd", timeZone);
297    }
298    /** Formats date as DD/MM/YY.
299     * @param d the date
300     * @return formatted date string
301     */
302    public static String getEuropeanDate(Date d) {
303        return formatDate (d, "ddMMyy");
304    }
305    /** Formats date as DD/MM/YY in the given timezone.
306     * @param d        the date
307     * @param timeZone the timezone
308     * @return formatted date string
309     */
310    public static String getEuropeanDate(Date d, TimeZone timeZone) {
311        return formatDate (d, "ddMMyy", timeZone);
312    }
313    /**
314     * Formats the given date.
315     * @return date in yyMM format - suitable for field 14
316     * @param d the date to format
317     */
318    public static String getExpirationDate(Date d) {
319        return formatDate (d, "yyMM");
320    }
321    /**
322         * Formats the given date.
323         * @param d date object to be formatted
324     * @param timeZone for GMT for example, use TimeZone.getTimeZone("GMT")
325     *        and for Uruguay use TimeZone.getTimeZone("GMT-03:00")
326     * @return date in yyMM format - suitable for field 14
327     */
328    public static String getExpirationDate(Date d, TimeZone timeZone) {
329        return formatDate (d, "yyMM", timeZone);
330    }
331
332    /**
333     * Formats the given date.
334     * @param d date object to be formatted
335     * @return date in YDDD format suitable for bit 31 or 37
336     * depending on interchange
337     */
338    public static String getJulianDate(Date d) {
339      String day = formatDate(d, "DDD", TimeZone.getDefault());
340      String year = formatDate(d, "yy", TimeZone.getDefault());
341      year = year.substring(1);
342      return year + day;
343    }
344
345    /**
346     * Formats the given date.
347     * @param d date object to be formatted
348     * @param timeZone for GMT for example, use TimeZone.getTimeZone("GMT")
349     * and for Uruguay use TimeZone.getTimeZone("GMT-03:00")
350     * @return date in YDDD format suitable for bit 31 or 37
351     * depending on interchange
352     */
353    public static String getJulianDate(Date d, TimeZone timeZone) {
354      String day = formatDate(d, "DDD", timeZone);
355      String year = formatDate(d, "yy", timeZone);
356      year = year.substring(1);
357      return year + day;
358    }
359
360    /**
361     * Calculates the closest year in full YYYY format based on a two-digit year input.
362     * The closest year is determined in relation to the current year provided by the Calendar instance.
363     *
364     * @param year The two-digit year to be converted (e.g., 23 for 2023 or 2123).
365     * @param now  The current date provided as a Calendar instance used for reference.
366     * @return The closest full year in YYYY format.
367     * @throws IllegalArgumentException if the input year is not between 0 and 99.
368     */
369    private static int calculateNearestFullYear(int year, Calendar now) {
370        if (year < 0 || year > 99) {
371            throw new IllegalArgumentException("Year must be between 0 and 99");
372        }
373
374        int currentYear = now.get(Calendar.YEAR); // e.g., 2023
375        int currentCentury = currentYear - currentYear % 100; // e.g., 2000 for 2023
376        int possibleYear = currentCentury + year; // e.g., 2023 for year 23
377
378        // Adjust to the closest century if needed
379        if (Math.abs(year - currentYear % 100) > 50) {
380            possibleYear += (year > currentYear % 100) ? -100 : 100;
381        }
382        return possibleYear;
383    }
384
385    /**
386     * Formats {@code d} as a human-readable {@code "Nd, Nh, Nm, Ns"} string,
387     * omitting zero components.
388     *
389     * @param d duration to format
390     * @return the formatted duration string
391     */
392    public static String formatDuration(Duration d) {
393        long days = d.toDays();
394        long hours = d.toHoursPart();
395        long minutes = d.toMinutesPart();
396        long seconds = d.toSecondsPart();
397        StringJoiner sj = new StringJoiner(", ");
398        if (days > 0)
399            sj.add(days + "d");
400        if (hours > 0)
401            sj.add(hours + "h");
402        if (minutes > 0)
403            sj.add(minutes + "m");
404        if (seconds > 0 || sj.length() == 0)
405            sj.add(seconds + "s");
406        return sj.toString();
407    }
408}