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}