diff --git a/src/main/java/org/kopi/ebics/client/EbicsClient.java b/src/main/java/org/kopi/ebics/client/EbicsClient.java index 8479a7f5..b1ca0cc8 100644 --- a/src/main/java/org/kopi/ebics/client/EbicsClient.java +++ b/src/main/java/org/kopi/ebics/client/EbicsClient.java @@ -58,6 +58,7 @@ import org.kopi.ebics.session.OrderType; import org.kopi.ebics.session.Product; import org.kopi.ebics.utils.Constants; +import org.kopi.ebics.xml.EbicsXmlFactory; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -413,6 +414,17 @@ public void sendFile(File file, EbicsOrderType orderType) throws Exception { public void fetchFile(File file, User user, Product product, EbicsOrderType orderType, boolean isTest) throws IOException, EbicsException { + fetchFile(file, user, product, orderType, null, isTest); + } + + /** + * Downloads a file from the bank. + * + * @param downloadParams optional EBICS 3.0 service parameters and report period; with a + * service name set the order is sent as a BTD business transaction format order + */ + public void fetchFile(File file, User user, Product product, EbicsOrderType orderType, + EbicsDownloadParams downloadParams, boolean isTest) throws IOException, EbicsException { FileTransfer transferManager; EbicsSession session = createSession(user, product); session.addSessionParam("FORMAT", "pain.xxx.cfonb160.dct"); @@ -425,7 +437,7 @@ public void fetchFile(File file, User user, Product product, EbicsOrderType orde configuration.getTransferTraceDirectory(user)); try { - transferManager.fetchFile(orderType, file); + transferManager.fetchFile(orderType, downloadParams, file); } catch (NoDownloadDataAvailableException e) { // don't log this exception as an error, caller can decide how to handle throw e; @@ -435,9 +447,22 @@ public void fetchFile(File file, User user, Product product, EbicsOrderType orde } } + /** + * Downloads a file for a report period. + * + *

A {@link Date} is an instant, the EBICS report period is a pair of calendar days. + * The calendar day is therefore read in the timezone of the machine running this code, so a + * {@code Date} at UTC midnight becomes the previous day in any zone west of UTC. Prefer + * {@link #fetchFile(File, User, Product, EbicsOrderType, EbicsDownloadParams, boolean)} with + * {@link java.time.LocalDate} values, which has no timezone in it. + */ public void fetchFile(File file, EbicsOrderType orderType, Date start, Date end) throws IOException, EbicsException { - fetchFile(file, defaultUser, defaultProduct, orderType, false); + fetchFile(file, defaultUser, defaultProduct, orderType, + EbicsDownloadParams.dateRangeOnly( + start == null ? null : EbicsXmlFactory.toLocalDate(start), + end == null ? null : EbicsXmlFactory.toLocalDate(end)), + false); } /** diff --git a/src/main/java/org/kopi/ebics/client/EbicsDownloadParams.java b/src/main/java/org/kopi/ebics/client/EbicsDownloadParams.java new file mode 100644 index 00000000..9a701fec --- /dev/null +++ b/src/main/java/org/kopi/ebics/client/EbicsDownloadParams.java @@ -0,0 +1,56 @@ +package org.kopi.ebics.client; + +import java.time.LocalDate; + +/** + * Service parameters for an EBICS 3.0 (H005) BTD download order. + * + *

With a {@code serviceName} set, the request is sent as {@code AdminOrderType=BTD} with a + * {@code BTDOrderParams/Service} block. With {@code serviceName} left {@code null}, only the + * optional date range is applied and the legacy EBICS 2.x order type is kept, so existing + * callers keep their behaviour. + * + *

The report period is a pair of calendar days ({@link LocalDate}), not instants: EBICS sends + * it as {@code xs:date}, and a timezone in that position only creates off-by-one-day bugs. + * + *

The constructor rejects a partial or reversed range. Both would otherwise travel silently: + * a half range is dropped when the request is built, and a reversed one is schema-valid and comes + * back as "no data available", which is indistinguishable from a period that really was empty. + * This is the single place every caller passes through, so the check lives here rather than in + * each caller. + */ +public record EbicsDownloadParams( + String serviceName, + String scope, + String option, + String messageName, + String messageVersion, + String containerType, + LocalDate startDate, + LocalDate endDate) { + + public EbicsDownloadParams { + if ((startDate == null) != (endDate == null)) { + throw new IllegalArgumentException( + "startDate and endDate must be given together (--start/--end); a single one" + + " would be dropped from the bank request"); + } + if (startDate != null && endDate.isBefore(startDate)) { + throw new IllegalArgumentException( + "endDate must not be before startDate, got " + startDate + " to " + endDate); + } + } + + /** Date-range-only parameters for the legacy (non-BTD) download path. */ + public static EbicsDownloadParams dateRangeOnly(LocalDate startDate, LocalDate endDate) { + if (startDate == null && endDate == null) { + return null; + } + return new EbicsDownloadParams(null, null, null, null, null, null, startDate, endDate); + } + + /** Whether these parameters describe an EBICS 3.0 BTD business transaction format order. */ + public boolean isBtd() { + return serviceName != null; + } +} diff --git a/src/main/java/org/kopi/ebics/client/FileTransfer.java b/src/main/java/org/kopi/ebics/client/FileTransfer.java index 001571f1..c4151144 100644 --- a/src/main/java/org/kopi/ebics/client/FileTransfer.java +++ b/src/main/java/org/kopi/ebics/client/FileTransfer.java @@ -173,9 +173,27 @@ public void sendFile(ContentFactory factory, public void fetchFile(EbicsOrderType orderType, File outputFile) throws IOException, EbicsException + { + fetchFile(orderType, null, outputFile); + } + + /** + * Fetches a file of the given order type from the bank. + * This type of transfer will run until everything is processed. + * No transaction recovery is possible. + * @param orderType type of file to fetch + * @param downloadParams optional EBICS 3.0 service parameters and report period + * @param outputFile where to put the data + * @throws IOException communication error + * @throws EbicsException server generated error + */ + public void fetchFile(EbicsOrderType orderType, + EbicsDownloadParams downloadParams, + File outputFile) + throws IOException, EbicsException { var sender = new HttpRequestSender(session); - var initializer = new DownloadInitializationRequestElement(session, orderType); + var initializer = new DownloadInitializationRequestElement(session, orderType, downloadParams); initializer.build(); initializer.validate(); diff --git a/src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java b/src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java index b220222f..08d6918d 100644 --- a/src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java +++ b/src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java @@ -20,11 +20,18 @@ import java.io.File; import java.net.URL; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeParseException; +import java.util.Date; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.Locale; +import java.util.Map; import java.util.Properties; import java.util.Set; import org.kopi.ebics.interfaces.EbicsBank; +import org.kopi.ebics.interfaces.EbicsOrderType; import org.kopi.ebics.interfaces.EbicsPartner; import org.kopi.ebics.interfaces.PasswordCallback; import org.kopi.ebics.session.DefaultConfiguration; @@ -41,9 +48,16 @@ public final class ParameterizedEbicsClientLauncher { "--ini", "--hia", "--hpb", - "--help" + "--help", + "--btd" ); + /** + * EBICS 3.0 business transaction downloads always use the admin order type {@code BTD}; the + * business order is carried by the service parameters instead of the 3-letter code. + */ + private static final EbicsOrderType BTD_ORDER_TYPE = () -> "BTD"; + private ParameterizedEbicsClientLauncher() { } @@ -54,6 +68,11 @@ public static void main(String[] args) throws Exception { return; } + // Every argument is checked before the first environment read, keystore access or bank + // call. INI is one-shot at most banks: aborting on a missing --container after the INI + // request has gone out would leave a half-initialised access behind. + validateArguments(parsedArguments); + String passphrase = requiredEnv("EBICS_PASSWORD"); String userId = requiredEnv("EBICS_USER_ID"); String partnerId = requiredEnv("EBICS_PARTNER_ID"); @@ -117,6 +136,20 @@ public static void main(String[] args) throws Exception { client.sendHPBRequest(user, product); } + if (parsedArguments.hasFlag("--btd")) { + EbicsDownloadParams downloadParams = btdDownloadParams(parsedArguments); + client.fetchFile( + new File(requireOutputPath(parsedArguments)), + user, + product, + BTD_ORDER_TYPE, + downloadParams, + Boolean.parseBoolean(env("EBICS_TEST_MODE", "false")) + ); + client.quit(); + return; + } + String orderFlag = parsedArguments.firstOrderFlag(); if (orderFlag != null) { OrderType orderType = OrderType.valueOf(orderFlag.substring(2).toUpperCase(Locale.ROOT)); @@ -129,16 +162,12 @@ public static void main(String[] args) throws Exception { defaultUploadParams(user, orderType) ); } else if (parsedArguments.outputPath() != null) { - if (parsedArguments.startDate() != null || parsedArguments.endDate() != null) { - System.err.println( - "Date range arguments are ignored in parameterized mode for this order type." - ); - } client.fetchFile( new File(parsedArguments.outputPath()), user, product, orderType, + legacyDownloadParams(parsedArguments), Boolean.parseBoolean(env("EBICS_TEST_MODE", "false")) ); } @@ -149,12 +178,95 @@ public static void main(String[] args) throws Exception { private static void printUsage() { String usage = "Usage: ParameterizedEbicsClientLauncher [--create] [--ini] [--hia] [--hpb]" - + " [--] [-i inputFile] [-o outputFile]\n" + + " [--] [-i inputFile] [-o outputFile] [-s start] [-e end]\n" + + "EBICS 3.0 download: --btd --service --scope --msg-name " + + " --msg-version --container " + + " [--option ] [-s YYYY-MM-DD] [-e YYYY-MM-DD] -o \n" + + " e.g. --btd --service EOP --scope CH --msg-name camt.053 --msg-version 08" + + " --container ZIP -o statement.zip\n" + "Required environment variables: EBICS_PASSWORD, EBICS_USER_ID, EBICS_PARTNER_ID," + " EBICS_HOST_ID, EBICS_BANK_URL"; System.out.println(usage); } + /** + * Rejects every unusable argument combination before the program talks to anyone. Nothing here + * touches the network, the filesystem or the environment. + */ + static void validateArguments(ParsedArguments parsedArguments) { + if (parsedArguments.hasFlag("--btd")) { + btdDownloadParams(parsedArguments); + requireOutputPath(parsedArguments); + } else { + legacyDownloadParams(parsedArguments); + } + } + + /** + * Builds the EBICS 3.0 service parameters for {@code --btd}. Fails fast on a missing mandatory + * value, so a half-filled order is never sent to the bank. The date range pair itself is + * checked by {@link EbicsDownloadParams}, which covers every other caller too. + */ + static EbicsDownloadParams btdDownloadParams(ParsedArguments parsedArguments) { + return new EbicsDownloadParams( + upperCase(requireOption(parsedArguments.serviceName(), "--service")), + upperCase(requireOption(parsedArguments.scope(), "--scope")), + upperCase(parsedArguments.option()), + requireOption(parsedArguments.messageName(), "--msg-name"), + requireOption(parsedArguments.messageVersion(), "--msg-version"), + upperCase(requireOption(parsedArguments.containerType(), "--container")), + parseDate(parsedArguments.startDate(), "--start"), + parseDate(parsedArguments.endDate(), "--end") + ); + } + + /** + * Service code, scope, service option and container type are EBICS code list values and are + * always upper case. Message names like {@code camt.053} are not, and stay untouched. + */ + private static String upperCase(String value) { + return value == null ? null : value.toUpperCase(Locale.ROOT); + } + + /** + * Builds the date-range-only parameters of the legacy (EBICS 2.x) download path. + */ + static EbicsDownloadParams legacyDownloadParams(ParsedArguments parsedArguments) { + return EbicsDownloadParams.dateRangeOnly( + parseDate(parsedArguments.startDate(), "--start"), + parseDate(parsedArguments.endDate(), "--end") + ); + } + + static String requireOutputPath(ParsedArguments parsedArguments) { + return requireOption(parsedArguments.outputPath(), "-o"); + } + + private static String requireOption(String value, String option) { + String normalized = normalize(value); + if (normalized == null) { + throw new IllegalArgumentException("Missing required option " + option + " for --btd"); + } + return normalized; + } + + /** + * Parses a {@code YYYY-MM-DD} argument into a calendar day. No timezone is involved, so the + * day the user typed is the day that reaches the bank, wherever the job runs. + */ + private static LocalDate parseDate(String value, String option) { + String normalized = normalize(value); + if (normalized == null) { + return null; + } + try { + return LocalDate.parse(normalized); + } catch (DateTimeParseException e) { + throw new IllegalArgumentException( + "Option " + option + " expects a date as YYYY-MM-DD but was: " + normalized); + } + } + private static EbicsUploadParams defaultUploadParams(User user, OrderType orderType) { if (orderType == OrderType.XE2) { var orderParams = new EbicsUploadParams.OrderParams( @@ -290,6 +402,7 @@ static String normalize(String value) { static final class ParsedArguments { private final Set flags = new LinkedHashSet<>(); + private final Map values; private final String inputPath; private final String outputPath; private final String startDate; @@ -297,20 +410,33 @@ static final class ParsedArguments { private ParsedArguments( Set flags, + Map values, String inputPath, String outputPath, String startDate, String endDate ) { this.flags.addAll(flags); + this.values = Map.copyOf(values); this.inputPath = inputPath; this.outputPath = outputPath; this.startDate = startDate; this.endDate = endDate; } + /** Value options of the EBICS 3.0 service block; each consumes the following argument. */ + private static final Set VALUE_OPTIONS = Set.of( + "--service", + "--scope", + "--option", + "--msg-name", + "--msg-version", + "--container" + ); + static ParsedArguments parse(String[] args) { Set flags = new LinkedHashSet<>(); + Map values = new LinkedHashMap<>(); String inputPath = null; String outputPath = null; String startDate = null; @@ -337,12 +463,17 @@ static ParsedArguments parse(String[] args) { endDate = requireValue(args, ++index, arg); continue; } + String lowered = arg.toLowerCase(Locale.ROOT); + if (VALUE_OPTIONS.contains(lowered)) { + values.put(lowered, requireValue(args, ++index, arg)); + continue; + } if (arg.startsWith("--")) { - flags.add(arg.toLowerCase(Locale.ROOT)); + flags.add(lowered); } } } - return new ParsedArguments(flags, inputPath, outputPath, startDate, endDate); + return new ParsedArguments(flags, values, inputPath, outputPath, startDate, endDate); } private static String requireValue(String[] args, int index, String option) { @@ -393,5 +524,29 @@ String startDate() { String endDate() { return endDate; } + + String serviceName() { + return values.get("--service"); + } + + String scope() { + return values.get("--scope"); + } + + String option() { + return values.get("--option"); + } + + String messageName() { + return values.get("--msg-name"); + } + + String messageVersion() { + return values.get("--msg-version"); + } + + String containerType() { + return values.get("--container"); + } } } diff --git a/src/main/java/org/kopi/ebics/xml/DownloadInitializationRequestElement.java b/src/main/java/org/kopi/ebics/xml/DownloadInitializationRequestElement.java index df1d29cc..2c102ee8 100644 --- a/src/main/java/org/kopi/ebics/xml/DownloadInitializationRequestElement.java +++ b/src/main/java/org/kopi/ebics/xml/DownloadInitializationRequestElement.java @@ -20,8 +20,12 @@ import java.util.Calendar; +import org.apache.xmlbeans.SchemaType; +import org.apache.xmlbeans.XmlObject; +import org.kopi.ebics.client.EbicsDownloadParams; import org.kopi.ebics.exception.EbicsException; import org.kopi.ebics.interfaces.EbicsOrderType; +import org.kopi.ebics.schema.h005.BTDOrderParamsDocument; import org.kopi.ebics.schema.h005.EbicsRequestDocument.EbicsRequest; import org.kopi.ebics.schema.h005.EbicsRequestDocument.EbicsRequest.Body; import org.kopi.ebics.schema.h005.EbicsRequestDocument.EbicsRequest.Header; @@ -52,7 +56,21 @@ public class DownloadInitializationRequestElement extends InitializationRequestE */ public DownloadInitializationRequestElement(EbicsSession session, EbicsOrderType type) { + this(session, type, null); + } + + /** + * Constructs a new DInitializationRequestElement for downloads initializations. + * @param session the current ebics session + * @param type the download order type (FDL, HTD, HPD) + * @param downloadParams optional service parameters; with a service name set the request is + * sent as an EBICS 3.0 BTD order, otherwise the legacy order type is kept + */ + public DownloadInitializationRequestElement(EbicsSession session, + EbicsOrderType type, + EbicsDownloadParams downloadParams) { super(session, type, generateName(type)); + this.downloadParams = downloadParams; } @Override @@ -78,16 +96,39 @@ public void buildInitialization() throws EbicsException { decodeHex(session.getUser().getPartner().getBank().getE002Digest())); bankPubKeyDigests = EbicsXmlFactory.createBankPubKeyDigests(authentication, encryption); - StandardOrderParamsType standardOrderParamsType = EbicsXmlFactory.createStandardOrderParamsType(); - var type = StaticHeaderOrderDetailsType.AdminOrderType.Factory.newInstance(); - type.setStringValue(this.getType()); + + XmlObject orderParamsType; + SchemaType orderParamsSchema; + + if (downloadParams != null && downloadParams.isBtd()) { + // EBICS 3.0: the business transaction goes into the service block, the admin order + // type is always BTD. + type.setStringValue("BTD"); + orderParamsType = EbicsXmlFactory.createBTDParams( + downloadParams.serviceName(), downloadParams.scope(), downloadParams.option(), + downloadParams.messageName(), downloadParams.messageVersion(), + downloadParams.containerType(), downloadParams.startDate(), + downloadParams.endDate()); + orderParamsSchema = BTDOrderParamsDocument.type; + } else { + type.setStringValue(this.getType()); + StandardOrderParamsType standardOrderParamsType = + EbicsXmlFactory.createStandardOrderParamsType(); + // EbicsDownloadParams guarantees the range is either absent or complete. + if (downloadParams != null && downloadParams.startDate() != null) { + standardOrderParamsType.setDateRange(EbicsXmlFactory.createDateRange( + downloadParams.startDate(), downloadParams.endDate())); + } + orderParamsType = standardOrderParamsType; + orderParamsSchema = StandardOrderParamsDocument.type; + } //FIXME Some banks cannot handle OrderID element in download process. Add parameter in configuration!!! orderDetails = EbicsXmlFactory.createStaticHeaderOrderDetailsType(null,//session.getUser().getPartner().nextOrderId(), type, - standardOrderParamsType, - StandardOrderParamsDocument.type); + orderParamsType, + orderParamsSchema); xstatic = EbicsXmlFactory.createStaticHeaderType(session.getBankID(), nonce, @@ -107,5 +148,6 @@ public void buildInitialization() throws EbicsException { document = EbicsXmlFactory.createEbicsRequestDocument(request); } + private final EbicsDownloadParams downloadParams; private static final long serialVersionUID = 3776072549761880272L; } diff --git a/src/main/java/org/kopi/ebics/xml/EbicsXmlFactory.java b/src/main/java/org/kopi/ebics/xml/EbicsXmlFactory.java index ddd8137d..0e479f99 100644 --- a/src/main/java/org/kopi/ebics/xml/EbicsXmlFactory.java +++ b/src/main/java/org/kopi/ebics/xml/EbicsXmlFactory.java @@ -18,6 +18,8 @@ package org.kopi.ebics.xml; +import java.time.LocalDate; +import java.time.ZoneId; import java.util.Calendar; import java.util.Date; @@ -33,8 +35,11 @@ import org.ebics.s002.UserSignatureDataDocument; import org.ebics.s002.UserSignatureDataSigBookType; import org.kopi.ebics.schema.h005.AuthenticationPubKeyInfoType; +import org.kopi.ebics.schema.h005.BTDParamsType; import org.kopi.ebics.schema.h005.BTUOrderParamsDocument; import org.kopi.ebics.schema.h005.BTUParamsType; +import org.kopi.ebics.schema.h005.ContainerStringType; +import org.kopi.ebics.schema.h005.DateType; import org.kopi.ebics.schema.h005.DataDigestType; import org.kopi.ebics.schema.h005.DataEncryptionInfoType.EncryptionPubKeyDigest; import org.kopi.ebics.schema.h005.DataTransferRequestType; @@ -920,6 +925,69 @@ public static BTUParamsType createBTUParams(String serviceName, String scope, St return type; } + /** + * Creates the order parameters of an EBICS 3.0 (H005) BTD download order. + * + * @param serviceName the BTF service code, e.g. {@code EOP} + * @param scope the rule scope, e.g. {@code CH}; may be {@code null} + * @param option the service option; may be {@code null} + * @param messageName the message name, e.g. {@code camt.053} + * @param messageVersion the message version, e.g. {@code 08} + * @param containerType the container type ({@code XML}, {@code ZIP} or {@code SVC}); + * may be {@code null} + * @param start the first calendar day of the requested report period; may be + * {@code null} + * @param end the last calendar day of the requested report period; may be + * {@code null} + * @return the BTDParamsType XML object + */ + public static BTDParamsType createBTDParams(String serviceName, String scope, String option, + String messageName, String messageVersion, String containerType, + LocalDate start, LocalDate end) { + var type = BTDParamsType.Factory.newInstance(); + var service = type.addNewService(); + service.setServiceName(serviceName); + if (scope != null) { + service.setScope(scope); + } + if (option != null) { + service.setServiceOption(option); + } + if (containerType != null) { + // The container flag lives inside Service (not directly in BTDParamsType) and the + // generated setter takes the enum, not a String. + var container = ContainerStringType.Enum.forString(containerType); + if (container == null) { + throw new IllegalArgumentException( + "Unsupported EBICS container type: " + containerType); + } + service.addNewContainer().setContainerType(container); + } + var msgType = MessageType.Factory.newInstance(); + msgType.setStringValue(messageName); + msgType.setVersion(messageVersion); + service.setMsgName(msgType); + if (start != null && end != null) { + var range = type.addNewDateRange(); + range.xsetStart(toXmlDate(start)); + range.xsetEnd(toXmlDate(end)); + } + return type; + } + + /** + * Converts a calendar day into an xs:date value. No timezone is involved in + * either direction: setting a {@link Calendar} would make XMLBeans append the local offset + * (e.g. {@code 2026-08-10+02:00}), which shifts the reported day for a bank in another + * timezone, and converting through an instant would make the day itself depend on the + * machine's zone. + */ + private static DateType toXmlDate(LocalDate date) { + var value = DateType.Factory.newInstance(); + value.setStringValue(date.toString()); + return value; + } + // private static StaticHeaderOrderDetailsType createStaticHeaderOrderDetailsType(String orderId, // OrderAttributeType.Enum orderAttribute, OrderType orderType, XmlObject orderParams, // QName newInstance) { @@ -971,25 +1039,45 @@ public static StandardOrderParamsType createStandardOrderParamsType() { } /** - * Creates a new DateRange XML object + * Creates a new DateRange XML object. + * + *

A {@link Date} is an instant, the EBICS date range is a pair of calendar days. + * The calendar day is therefore taken in the timezone of the machine running this code: a + * {@code Date} at UTC midnight becomes the previous day in any zone west of UTC. Prefer + * {@link #createDateRange(LocalDate, LocalDate)} — that overload has no timezone in it. * * @param start the start range * @param end the end range * @return the DateRange XML object */ public static StandardOrderParamsType.DateRange createDateRange(Date start, Date end) { + return createDateRange(toLocalDate(start), toLocalDate(end)); + } + + /** + * Creates a new DateRange XML object from two calendar days. + * + * @param start the first day of the range + * @param end the last day of the range + * @return the DateRange XML object + */ + public static StandardOrderParamsType.DateRange createDateRange(LocalDate start, LocalDate end) { StandardOrderParamsType.DateRange newDateRange = StandardOrderParamsType.DateRange.Factory.newInstance(); - Calendar startRange = Calendar.getInstance(); - Calendar endRange = Calendar.getInstance(); - startRange.setTime(start); - endRange.setTime(end); - newDateRange.setStart(startRange); - newDateRange.setEnd(endRange); + newDateRange.xsetStart(toXmlDate(start)); + newDateRange.xsetEnd(toXmlDate(end)); return newDateRange; } + /** + * Reads the calendar day out of an instant, in the timezone of this machine. Only for the + * {@link Date}-based compatibility overloads; anything new should carry a {@link LocalDate}. + */ + public static LocalDate toLocalDate(Date date) { + return date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate(); + } + // /** // * Creates a new FileFormatType XML object // * diff --git a/src/test/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncherTest.java b/src/test/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncherTest.java index 5efaee4a..45a0e9e3 100644 --- a/src/test/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncherTest.java +++ b/src/test/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncherTest.java @@ -1,9 +1,11 @@ package org.kopi.ebics.client; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; import org.junit.jupiter.api.Test; @@ -39,6 +41,188 @@ void rejectsMissingOptionValue() { assertTrue(exception.getMessage().contains("Missing value for option -o")); } + @Test + void parsesBtdServiceOptions() { + var parsed = ParameterizedEbicsClientLauncher.ParsedArguments.parse( + new String[]{ + "--btd", "--service", "EOP", "--scope", "CH", "--msg-name", "camt.053", + "--msg-version", "08", "--container", "ZIP", + "-s", "2026-08-10", "-e", "2026-08-11", "-o", "statement.zip" + } + ); + + assertTrue(parsed.hasFlag("--btd")); + assertNull(parsed.firstOrderFlag(), "--btd is reserved and must not be read as order type"); + + var params = ParameterizedEbicsClientLauncher.btdDownloadParams(parsed); + + assertEquals("EOP", params.serviceName()); + assertEquals("CH", params.scope()); + assertEquals("camt.053", params.messageName()); + assertEquals("08", params.messageVersion()); + assertEquals("ZIP", params.containerType()); + assertNull(params.option()); + assertNotNull(params.startDate()); + assertNotNull(params.endDate()); + assertEquals("statement.zip", ParameterizedEbicsClientLauncher.requireOutputPath(parsed)); + } + + @Test + void rejectsBtdWithoutMandatoryServiceValues() { + var parsed = ParameterizedEbicsClientLauncher.ParsedArguments.parse( + new String[]{ "--btd", "--service", "EOP", "-o", "statement.zip" } + ); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> ParameterizedEbicsClientLauncher.btdDownloadParams(parsed) + ); + assertTrue( + exception.getMessage().contains("Missing required option --scope for --btd"), + "Expected a clear abort naming the missing option, got: " + exception.getMessage() + ); + } + + @Test + void rejectsBtdWithoutOutputPath() { + var parsed = ParameterizedEbicsClientLauncher.ParsedArguments.parse( + new String[]{ + "--btd", "--service", "EOP", "--scope", "CH", "--msg-name", "camt.053", + "--msg-version", "08", "--container", "ZIP" + } + ); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> ParameterizedEbicsClientLauncher.requireOutputPath(parsed) + ); + assertTrue(exception.getMessage().contains("Missing required option -o for --btd")); + } + + @Test + void rejectsMalformedDateRange() { + var parsed = ParameterizedEbicsClientLauncher.ParsedArguments.parse( + new String[]{ + "--btd", "--service", "EOP", "--scope", "CH", "--msg-name", "camt.053", + "--msg-version", "08", "--container", "ZIP", + "-s", "10.08.2026", "-e", "2026-08-11" + } + ); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> ParameterizedEbicsClientLauncher.btdDownloadParams(parsed) + ); + assertTrue(exception.getMessage().contains("--start expects a date as YYYY-MM-DD")); + } + + @Test + void rejectsHalfDateRange() { + var parsed = ParameterizedEbicsClientLauncher.ParsedArguments.parse( + new String[]{ + "--btd", "--service", "EOP", "--scope", "CH", "--msg-name", "camt.053", + "--msg-version", "08", "--container", "ZIP", "-s", "2026-08-10" + } + ); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> ParameterizedEbicsClientLauncher.btdDownloadParams(parsed) + ); + assertTrue( + exception.getMessage().contains("must be given together"), + "A half date range must abort instead of being dropped silently: " + + exception.getMessage() + ); + } + + /** + * I-1: the legacy (non-BTD) path dropped a half date range silently as well, and the former + * System.err warning was gone. Aborting beats warning: a catch-up run that believes it asked + * for a period but did not is the exact failure this order type exists to prevent. + */ + @Test + void rejectsHalfDateRangeOnLegacyPathToo() { + var parsed = ParameterizedEbicsClientLauncher.ParsedArguments.parse( + new String[]{ "--c53", "-o", "auszug.xml", "-s", "2026-08-01" } + ); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> ParameterizedEbicsClientLauncher.legacyDownloadParams(parsed) + ); + assertTrue( + exception.getMessage().contains("must be given together"), + "The legacy path must not silently drop a half date range: " + exception.getMessage() + ); + } + + /** M-4: a reversed range is schema-valid and indistinguishable from "no data available". */ + @Test + void rejectsReversedDateRange() { + var parsed = ParameterizedEbicsClientLauncher.ParsedArguments.parse( + new String[]{ + "--btd", "--service", "EOP", "--scope", "CH", "--msg-name", "camt.053", + "--msg-version", "08", "--container", "ZIP", + "-s", "2026-08-11", "-e", "2026-08-10" + } + ); + + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> ParameterizedEbicsClientLauncher.btdDownloadParams(parsed) + ); + assertTrue( + exception.getMessage().contains("must not be before"), + "Expected the reversed range to be named: " + exception.getMessage() + ); + } + + /** M-1: the container type is an EBICS code list value, casing is not the user's problem. */ + @Test + void normalizesCaseOfServiceCodes() { + var parsed = ParameterizedEbicsClientLauncher.ParsedArguments.parse( + new String[]{ + "--btd", "--service", "eop", "--scope", "ch", "--msg-name", "camt.053", + "--msg-version", "08", "--container", "zip" + } + ); + + var params = ParameterizedEbicsClientLauncher.btdDownloadParams(parsed); + + assertEquals("ZIP", params.containerType(), "--container zip must not abort"); + assertEquals("EOP", params.serviceName(), "service codes are upper case in EBICS"); + assertEquals("CH", params.scope(), "the scope is an ISO country or issuer code"); + assertEquals("camt.053", params.messageName(), "message names stay as given"); + } + + /** + * I-3: every argument has to be checked before anything reaches the bank. The guard used to sit + * after loadUser/createUser and after --ini/--hia/--hpb, so an incomplete --btd order could + * still fire an INI request first, and INI is one-shot at most banks. + * + *

Proven by ordering: with no EBICS_* environment set, main() must fail on the argument, not + * on the environment variable it reads later. + */ + @Test + void validatesArgumentsBeforeAnyBankContact() { + assumeTrue(System.getenv("EBICS_PASSWORD") == null, + "needs an environment without live EBICS credentials"); + + Exception exception = assertThrows( + IllegalArgumentException.class, + () -> ParameterizedEbicsClientLauncher.main(new String[]{ + "--ini", "--btd", "--service", "EOP", "--scope", "CH", + "--msg-name", "camt.053", "--msg-version", "08", "-o", "statement.zip" + }) + ); + assertTrue( + exception.getMessage().contains("Missing required option --container"), + "Arguments must be rejected before the first environment read or bank call, got: " + + exception.getMessage() + ); + } + @Test void normalizeHandlesBlankValues() { assertNull(ParameterizedEbicsClientLauncher.normalize(" ")); diff --git a/src/test/java/org/kopi/ebics/xml/DownloadInitializationRequestElementTest.java b/src/test/java/org/kopi/ebics/xml/DownloadInitializationRequestElementTest.java new file mode 100644 index 00000000..49b4c976 --- /dev/null +++ b/src/test/java/org/kopi/ebics/xml/DownloadInitializationRequestElementTest.java @@ -0,0 +1,129 @@ +package org.kopi.ebics.xml; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.TimeZone; +import org.apache.xmlbeans.XmlError; +import org.apache.xmlbeans.XmlOptions; +import org.junit.jupiter.api.Test; +import org.kopi.ebics.client.EbicsDownloadParams; + +class DownloadInitializationRequestElementTest { + + @Test + void buildsBtdRequestWithSwissCamt053ServiceParams() throws Exception { + var params = new EbicsDownloadParams( + "EOP", "CH", null, "camt.053", "08", "ZIP", + localDate(2026, 8, 10), + localDate(2026, 8, 11)); + + String raw = TestSessions.buildDownloadInitializationXml(params); + System.out.println("=== BTD download initialization request ==="); + System.out.println(raw); + System.out.println("=== end of request ==="); + + String xml = stripNamespacePrefixes(raw); + + assertTrue(xml.contains("BTD"), + "EBICS 3.0 verlangt BTD als AdminOrderType, nicht den 3-Buchstaben-Code"); + assertTrue(xml.contains("EOP")); + assertTrue(xml.contains("CH")); + assertTrue(xml.contains(">camt.053<"), "MsgName fehlt"); + assertTrue(xml.matches("(?s).*]*version=\"08\".*"), "MsgName-Version fehlt"); + assertTrue(xml.matches("(?s).*]*containerType=\"ZIP\".*"), "Container fehlt"); + assertTrue(xml.contains(""), + "Ohne DateRange kann der naechtliche Job verpasste Tage nicht nachholen"); + assertTrue(xml.contains("2026-08-10"), + "DateRange-Start muss ein reines xs:date ohne Zeitzonen-Offset sein"); + assertTrue(xml.contains("2026-08-11"), + "DateRange-Ende muss ein reines xs:date ohne Zeitzonen-Offset sein"); + } + + /** + * I-2: der Kalendertag darf nicht an der Zeitzone des Rechners haengen. Frueher lief er als + * {@code Date} durch {@code ZoneId.systemDefault()} und wurde westlich von UTC zum Vortag. + */ + @Test + void keepsTheCalendarDayInAnyMachineTimezone() { + var original = TimeZone.getDefault(); + try { + for (String zone : new String[]{ + "Europe/Zurich", "America/Los_Angeles", "Pacific/Kiritimati", "UTC" }) { + TimeZone.setDefault(TimeZone.getTimeZone(zone)); + + var params = EbicsXmlFactory.createBTDParams("EOP", "CH", null, "camt.053", "08", + "ZIP", LocalDate.of(2026, 8, 10), LocalDate.of(2026, 8, 11)); + + assertTrue(params.xmlText().contains(">2026-08-10<"), + "Der Starttag muss in " + zone + " derselbe sein: " + params.xmlText()); + assertFalse(params.xmlText().contains("2026-08-09"), + "Tagesversatz in " + zone + ": " + params.xmlText()); + } + } finally { + TimeZone.setDefault(original); + } + } + + private static LocalDate localDate(int year, int month, int day) { + return LocalDate.of(year, month, day); + } + + /** Der Auftragsparameter-Block muss gegen das H005-Schema gueltig sein, sonst lehnt die Bank ab. */ + @Test + void btdOrderParamsAreSchemaValid() { + var params = EbicsXmlFactory.createBTDParams("EOP", "CH", null, "camt.053", "08", "ZIP", + localDate(2026, 8, 10), localDate(2026, 8, 11)); + + var errors = new ArrayList(); + boolean valid = params.validate(new XmlOptions().setErrorListener(errors)); + + assertTrue(valid, "BTDOrderParams ist nicht schemakonform: " + errors); + } + + /** Ein unbekannter Container-Typ muss abbrechen statt still zu verschwinden. */ + @Test + void rejectsUnknownContainerType() { + assertThrows(IllegalArgumentException.class, () -> EbicsXmlFactory.createBTDParams( + "EOP", "CH", null, "camt.053", "08", "TAR", null, null)); + } + + /** Ohne Service-Parameter muss der EBICS-2.x-Pfad unveraendert bleiben. */ + @Test + void keepsLegacyRequestUnchangedWithoutParams() throws Exception { + String xml = stripNamespacePrefixes(TestSessions.buildDownloadInitializationXml(null)); + + assertTrue(xml.contains("C53"), + "Ohne Parameter bleibt der 3-Buchstaben-Code der AdminOrderType"); + assertFalse(xml.contains("BTDOrderParams"), "Ohne Parameter darf kein BTD-Block entstehen"); + assertFalse(xml.contains(""), "Ohne Datumsbereich darf kein DateRange entstehen"); + } + + /** + * EbicsClient.fetchFile(file, orderType, start, end) hat den Datumsbereich bisher verworfen. + * Auf dem EBICS-2.x-Pfad landet er jetzt in StandardOrderParams, der Auftragstyp bleibt. + */ + @Test + void appliesDateRangeOnLegacyPathWithoutTurningIntoBtd() throws Exception { + var params = EbicsDownloadParams.dateRangeOnly( + localDate(2026, 8, 10), localDate(2026, 8, 11)); + + String xml = stripNamespacePrefixes(TestSessions.buildDownloadInitializationXml(params)); + + assertTrue(xml.contains("C53"), + "Ohne Service-Namen darf kein BTD-Auftrag daraus werden"); + assertFalse(xml.contains("BTDOrderParams"), "Ohne Service-Namen kein BTD-Block"); + assertTrue(xml.contains("2026-08-10"), + "Der Datumsbereich muss in der Anfrage landen, nicht verworfen werden"); + assertTrue(xml.contains("2026-08-11")); + } + + /** XMLBeans waehlt Namensraum-Praefixe frei; die duerfen den Test nicht kippen. */ + private static String stripNamespacePrefixes(String xml) { + return xml.replaceAll("<(/?)[A-Za-z0-9_.-]+:", "<$1") + .replaceAll("\\s+xmlns(:[A-Za-z0-9_.-]+)?=\"[^\"]*\"", ""); + } +} diff --git a/src/test/java/org/kopi/ebics/xml/TestSessions.java b/src/test/java/org/kopi/ebics/xml/TestSessions.java new file mode 100644 index 00000000..7b155df3 --- /dev/null +++ b/src/test/java/org/kopi/ebics/xml/TestSessions.java @@ -0,0 +1,64 @@ +package org.kopi.ebics.xml; + +import static org.mockito.Mockito.RETURNS_DEEP_STUBS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.nio.charset.StandardCharsets; +import java.security.Security; + +import org.apache.xml.security.Init; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.kopi.ebics.client.EbicsDownloadParams; +import org.kopi.ebics.session.EbicsSession; +import org.kopi.ebics.session.OrderType; + +/** + * Test helper that builds EBICS request elements against a stubbed session, so the generated + * XML can be asserted without a bank, keystore or persisted workspace. + */ +final class TestSessions { + + /** 32 bytes worth of hex characters; the production code hex-decodes the bank digests. */ + private static final byte[] DUMMY_DIGEST = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + .getBytes(StandardCharsets.US_ASCII); + + static { + Init.init(); + Security.addProvider(new BouncyCastleProvider()); + } + + private TestSessions() { + } + + /** + * Builds the download initialization request for the given service parameters and returns the + * canonical XML. + * + * @param params the EBICS 3.0 service parameters, or {@code null} for the legacy path + * @return the generated request XML + */ + static String buildDownloadInitializationXml(EbicsDownloadParams params) throws Exception { + var element = new DownloadInitializationRequestElement(stubSession(), OrderType.C53, params); + element.buildInitialization(); + return element.toPrettyString(); + } + + private static EbicsSession stubSession() throws Exception { + var session = mock(EbicsSession.class, RETURNS_DEEP_STUBS); + when(session.getBankID()).thenReturn("EBICSHOST"); + when(session.getProduct().getLanguage()).thenReturn("de"); + when(session.getProduct().getName()).thenReturn("test-product"); + when(session.getConfiguration().getAuthenticationVersion()).thenReturn("X002"); + when(session.getConfiguration().getEncryptionVersion()).thenReturn("E002"); + when(session.getConfiguration().getRevision()).thenReturn(1); + when(session.getConfiguration().getVersion()).thenReturn("H005"); + when(session.getUser().getUserId()).thenReturn("USER0001"); + when(session.getUser().getSecurityMedium()).thenReturn("0000"); + when(session.getUser().getPartner().getPartnerId()).thenReturn("PARTNER1"); + when(session.getUser().getPartner().getBank().getX002Digest()).thenReturn(DUMMY_DIGEST); + when(session.getUser().getPartner().getBank().getE002Digest()).thenReturn(DUMMY_DIGEST); + return session; + } +}