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.participant;
020
021import org.jdom2.Element;
022import org.jpos.core.*;
023import org.jpos.iso.ISOMsg;
024import org.jpos.q2.Q2;
025import org.jpos.q2.QFactory;
026import org.jpos.rc.CMF;
027import org.jpos.transaction.Context;
028import org.jpos.transaction.ContextConstants;
029import org.jpos.transaction.TransactionParticipant;
030import org.jpos.util.Caller;
031import org.jpos.util.Log;
032import org.jpos.util.LogEvent;
033import org.jpos.util.Logger;
034
035import java.io.Serializable;
036import java.math.BigInteger;
037import java.util.*;
038import java.util.regex.Matcher;
039import java.util.regex.Pattern;
040
041/**
042 * Transaction participant that picks a routing destination for an inbound
043 * request based on the cardholder's BIN ranges and PAN regular expressions
044 * declared in its XML configuration.
045 */
046@SuppressWarnings("unused")
047public class SelectDestination implements TransactionParticipant, Configurable, XmlConfigurable {
048    /** Default constructor; no instance state to initialise. */
049    public SelectDestination() {}
050    private String requestName;
051    private String destinationName;
052    private String defaultDestination;
053    private boolean failOnNoRoute;
054    private CardValidator validator;
055    private Set<BinRange> binranges = new TreeSet<>();
056    private List<PanRegExp> regexps = new ArrayList<>();
057
058    @Override
059    public int prepare(long id, Serializable context) {
060        Context ctx = (Context) context;
061        ISOMsg m = (ISOMsg) ctx.get(requestName);
062        boolean destinationSet = false;
063        if (m != null && (m.hasField(2) || m.hasField(35))) {
064            try {
065                Card card = Card.builder().validator(validator).isomsg(m).build();
066                String destination = getDestination(card);
067                if (destination != null) {
068                    ctx.put(destinationName, destination);
069                    destinationSet = true;
070                }
071            } catch (InvalidCardException ex) {
072                return ctx.getResult().fail(
073                  CMF.INVALID_CARD_OR_CARDHOLDER_NUMBER, Caller.info(), ex.getMessage()
074                ).FAIL();
075            }
076        }
077        if (!destinationSet && ctx.get(destinationName) == null)
078            ctx.put(destinationName, defaultDestination);
079        if (failOnNoRoute && ctx.get(destinationName) == null)
080            return ctx.getResult().fail(CMF.ROUTING_ERROR, Caller.info(), "No routing info").FAIL();
081
082        return PREPARED | NO_JOIN | READONLY;
083    }
084    
085    public void setConfiguration (Configuration cfg) {
086        this.requestName = cfg.get("request", ContextConstants.REQUEST.toString());
087        this.destinationName = cfg.get ("destination", ContextConstants.DESTINATION.toString());
088        this.defaultDestination = cfg.get("default-destination", null);
089        this.validator = cfg.getBoolean("ignore-card-validations") ?
090          new NoCardValidator() : cfg.getBoolean("ignore-luhn") ?
091          new IgnoreLuhnCardValidator() : Card.Builder.DEFAULT_CARD_VALIDATOR;
092        this.failOnNoRoute = cfg.getBoolean("fail");
093    }
094
095    /**
096     * SelectDestination expects an XML configuration in the following format:
097     *
098     * <pre>{@code
099     * <endpoint destination="XXX">
100     *   4000000..499999
101     *   4550000..455999
102     *   5
103     * </endpoint>
104     * }</pre>
105     *
106     * @param xml Configuration element
107     * @throws ConfigurationException if the XML cannot be parsed into BIN ranges or regexes
108     */
109    public void setConfiguration(Element xml) throws ConfigurationException {
110        for (Element ep : xml.getChildren("endpoint")) {
111            String destination = QFactory.getAttributeValue(ep, "destination");
112            StringTokenizer st = new StringTokenizer(Environment.get(ep.getTextTrim()));
113            while (st.hasMoreElements()) {
114                BinRange br = new BinRange(destination, st.nextToken());
115                binranges.add(br);
116            }
117        }
118        for (Element re : xml.getChildren("regexp")) {
119            String destination = QFactory.getAttributeValue(re, "destination");
120            regexps.add(
121              new PanRegExp(QFactory.getAttributeValue(re, "destination"), Environment.get(re.getTextTrim()))
122            );
123        }
124        LogEvent evt = Log.getLog(Q2.LOGGER_NAME, this.getClass().getName()).createLogEvent("config");
125        for (PanRegExp r : regexps)
126            evt.addMessage("00:" + r);
127        for (BinRange r : binranges)
128            evt.addMessage(r);
129        Logger.log(evt);
130    }
131
132    private String getDestination (Card card) {
133        String destination = getDestinationByRegExp(card.getPan());
134        if (destination == null)
135            destination = getDestinationByPanNumber(card.getPanAsNumber());
136        return destination;
137    }
138
139    private String getDestinationByPanNumber (BigInteger pan) {
140        final BigInteger p = BinRange.floor(pan);
141        return binranges
142          .stream()
143          .filter(r -> r.isInRange(p))
144          .findFirst()
145          .map(BinRange::destination).orElse(null);
146    }
147
148    private String getDestinationByRegExp (String pan) {
149        return regexps
150          .stream()
151          .filter(r -> r.matches(pan))
152          .findFirst()
153          .map(PanRegExp::destination).orElse(null);
154    }
155
156    /** Inclusive numeric BIN range mapped to a routing destination. */
157    public static class BinRange implements Comparable {
158        private String destination;
159        private BigInteger low;
160        private BigInteger high;
161        private short weight;
162        private static Pattern rangePattern = Pattern.compile("^([\\d]{1,19})*(?:\\.\\.)?([\\d]{0,19})?$");
163
164        /**
165         * Sample bin:
166         * <ul>
167         *     <li>4</li>
168         *     <li>411111</li>
169         *     <li>4111111111111111</li>
170         *     <li>411111..422222</li>
171         * </ul>
172         * @param destination range destination
173         * @param rangeExpr either a 'bin', 'extended bin' or 'bin range'
174         */
175        BinRange(String destination, String rangeExpr) {
176            this.destination = destination;
177            Matcher matcher = rangePattern.matcher(rangeExpr);
178            if (!matcher.matches())
179                throw new IllegalArgumentException("Invalid range " + rangeExpr);
180
181            String l = matcher.group(1);
182            String h = matcher.group(2);
183            h = h.isEmpty() ? l : h;
184            weight = (short) (Math.max(l.length(), h.length()));
185            low = floor(new BigInteger(l));
186            high = ceiling(new BigInteger(h));
187            if (low.compareTo(high) > 0)
188                throw new IllegalArgumentException("Invalid range " + low + "/" + high);
189        }
190
191        @Override
192        public boolean equals(Object o) {
193            if (this == o) return true;
194            if (o == null || getClass() != o.getClass()) return false;
195            BinRange binRange = (BinRange) o;
196            return weight == binRange.weight &&
197              Objects.equals(low, binRange.low) &&
198              Objects.equals(high, binRange.high);
199        }
200
201        @Override
202        public int hashCode() {
203            return Objects.hash(low, high, weight);
204        }
205
206        @Override
207        public String toString() {
208            return String.format("%02d:%s..%s [%s]", 19-weight, low, high, destination); // Warning, compareTo expects sorteable format
209        }
210        @Override
211        public int compareTo(Object o) {
212            return toString().compareTo(o.toString());
213        }
214        /**
215         * Indicates whether {@code i} falls inside this range (inclusive).
216         *
217         * @param i candidate BIN value
218         * @return {@code true} if {@code low <= i <= high}
219         */
220        public boolean isInRange (BigInteger i) {
221            return i.compareTo(low) >=0 && i.compareTo(high) <=0;
222        }
223
224        /**
225         * Returns the destination assigned to this range.
226         *
227         * @return destination identifier
228         */
229        public String destination() {
230            return destination;
231        }
232
233        static BigInteger floor(BigInteger i) {
234            if (!BigInteger.ZERO.equals(i)) {
235                int digits  = (int)Math.log10(i.doubleValue())+1;
236                i = i.multiply(BigInteger.TEN.pow(19-digits));
237            }
238            return i;
239        }
240        private BigInteger ceiling (BigInteger i) {
241            int digits  = BigInteger.ZERO.equals(i) ? 1 : (int)Math.log10(i.doubleValue())+1;
242            return floor(i).add(BigInteger.TEN.pow(19-digits)).subtract(BigInteger.ONE);
243        }
244    }
245
246    private static class PanRegExp {
247        private String destination;
248        private Pattern pattern;
249
250        PanRegExp(String destination, String regexp) {
251            this.destination = destination;
252            this.pattern = Pattern.compile(regexp);
253        }
254
255        public String destination() {
256            return destination;
257        }
258
259        public boolean matches(String pan) {
260            return pattern.matcher(pan).matches();
261        }
262
263        @Override
264        public boolean equals(Object o) {
265            if (this == o) return true;
266            if (o == null || getClass() != o.getClass()) return false;
267            PanRegExp panRegExp = (PanRegExp) o;
268            return Objects.equals(destination, panRegExp.destination) &&
269              Objects.equals(pattern, panRegExp.pattern);
270        }
271
272        @Override
273        public int hashCode() {
274            return Objects.hash(destination, pattern);
275        }
276
277        @Override
278        public String toString() {
279            return String.format("%s [%s]", pattern.toString(), destination);
280        }
281    }
282}