From b144eac3b94732ca3ec195c629db1c3875e10d24 Mon Sep 17 00:00:00 2001 From: Trofeomedia <274891666+Trofeomedia@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:39:35 +0200 Subject: [PATCH 1/4] feat(h005): support BTD download orders with service params and date range Downloads sent the 3-letter order code as AdminOrderType with empty StandardOrderParams, which EBICS 3.0 banks reject for customer data. This mirrors the existing BTU upload path: optional EbicsDownloadParams produce AdminOrderType=BTD with a BTDOrderParams/Service block, container type and an optional DateRange. Also fixes the date range that fetchFile(file, orderType, start, end) accepted and dropped: without a service name the legacy order type is kept and the range goes into StandardOrderParams. Behaviour without params is unchanged. Dates are written as plain xs:date; passing a Calendar made XMLBeans append the local offset (2026-08-10+02:00), which shifts the reported day for a bank in another timezone. Co-Authored-By: Claude Opus 5 (1M context) --- .../org/kopi/ebics/client/EbicsClient.java | 16 +- .../ebics/client/EbicsDownloadParams.java | 35 +++++ .../org/kopi/ebics/client/FileTransfer.java | 20 ++- .../ParameterizedEbicsClientLauncher.java | 143 ++++++++++++++++-- .../DownloadInitializationRequestElement.java | 52 ++++++- .../org/kopi/ebics/xml/EbicsXmlFactory.java | 71 ++++++++- .../ParameterizedEbicsClientLauncherTest.java | 96 ++++++++++++ ...nloadInitializationRequestElementTest.java | 110 ++++++++++++++ .../java/org/kopi/ebics/xml/TestSessions.java | 64 ++++++++ 9 files changed, 584 insertions(+), 23 deletions(-) create mode 100644 src/main/java/org/kopi/ebics/client/EbicsDownloadParams.java create mode 100644 src/test/java/org/kopi/ebics/xml/DownloadInitializationRequestElementTest.java create mode 100644 src/test/java/org/kopi/ebics/xml/TestSessions.java diff --git a/src/main/java/org/kopi/ebics/client/EbicsClient.java b/src/main/java/org/kopi/ebics/client/EbicsClient.java index 8479a7f5..454d0f14 100644 --- a/src/main/java/org/kopi/ebics/client/EbicsClient.java +++ b/src/main/java/org/kopi/ebics/client/EbicsClient.java @@ -413,6 +413,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 +436,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; @@ -437,7 +448,8 @@ public void fetchFile(File file, User user, Product product, EbicsOrderType orde 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, 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..83afad4f --- /dev/null +++ b/src/main/java/org/kopi/ebics/client/EbicsDownloadParams.java @@ -0,0 +1,35 @@ +package org.kopi.ebics.client; + +import java.util.Date; + +/** + * 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. + */ +public record EbicsDownloadParams( + String serviceName, + String scope, + String option, + String messageName, + String messageVersion, + String containerType, + Date startDate, + Date endDate) { + + /** Date-range-only parameters for the legacy (non-BTD) download path. */ + public static EbicsDownloadParams dateRangeOnly(Date startDate, Date 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..c15cfcfb 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() { } @@ -117,6 +131,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 +157,15 @@ 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, + EbicsDownloadParams.dateRangeOnly( + parseDate(parsedArguments.startDate(), "--start"), + parseDate(parsedArguments.endDate(), "--end") + ), Boolean.parseBoolean(env("EBICS_TEST_MODE", "false")) ); } @@ -149,12 +176,67 @@ 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); } + /** + * 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. + */ + static EbicsDownloadParams btdDownloadParams(ParsedArguments parsedArguments) { + // A half date range would be dropped silently further down, which is exactly how a + // catch-up run loses the days it was supposed to fetch. + if ((parsedArguments.startDate() == null) != (parsedArguments.endDate() == null)) { + throw new IllegalArgumentException( + "Options --start and --end must be given together, a single one is ignored" + + " by the bank request"); + } + return new EbicsDownloadParams( + requireOption(parsedArguments.serviceName(), "--service"), + requireOption(parsedArguments.scope(), "--scope"), + parsedArguments.option(), + requireOption(parsedArguments.messageName(), "--msg-name"), + requireOption(parsedArguments.messageVersion(), "--msg-version"), + requireOption(parsedArguments.containerType(), "--container"), + 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; + } + + private static Date parseDate(String value, String option) { + String normalized = normalize(value); + if (normalized == null) { + return null; + } + try { + return Date.from(LocalDate.parse(normalized) + .atStartOfDay(ZoneId.systemDefault()).toInstant()); + } 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 +372,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 +380,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 +433,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 +494,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..53021288 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(); + if (downloadParams != null + && downloadParams.startDate() != null && downloadParams.endDate() != 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..d5c33351 100644 --- a/src/main/java/org/kopi/ebics/xml/EbicsXmlFactory.java +++ b/src/main/java/org/kopi/ebics/xml/EbicsXmlFactory.java @@ -18,6 +18,7 @@ package org.kopi.ebics.xml; +import java.time.ZoneId; import java.util.Calendar; import java.util.Date; @@ -33,8 +34,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 +924,65 @@ 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 start of the requested report period; may be {@code null} + * @param end the end 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, Date start, Date 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 date into an xs:date value without a timezone offset. Passing a + * {@link Calendar} instead 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. + */ + private static DateType toXmlDate(Date date) { + var value = DateType.Factory.newInstance(); + value.setStringValue( + date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate().toString()); + return value; + } + // private static StaticHeaderOrderDetailsType createStaticHeaderOrderDetailsType(String orderId, // OrderAttributeType.Enum orderAttribute, OrderType orderType, XmlObject orderParams, // QName newInstance) { @@ -979,13 +1042,9 @@ public static StandardOrderParamsType createStandardOrderParamsType() { */ public static StandardOrderParamsType.DateRange createDateRange(Date start, Date 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; } diff --git a/src/test/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncherTest.java b/src/test/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncherTest.java index 5efaee4a..f436691f 100644 --- a/src/test/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncherTest.java +++ b/src/test/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncherTest.java @@ -1,6 +1,7 @@ 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; @@ -39,6 +40,101 @@ 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("--start and --end must be given together"), + "A half date range must abort instead of being dropped silently: " + + 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..47f610aa --- /dev/null +++ b/src/test/java/org/kopi/ebics/xml/DownloadInitializationRequestElementTest.java @@ -0,0 +1,110 @@ +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.time.ZoneId; +import java.util.ArrayList; +import java.util.Date; +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"); + } + + /** + * Der Kalendertag wird lokal gebildet, damit die Behauptung in jeder Zeitzone haelt. + * Fixe Epoch-Millis wuerden westlich von UTC auf den Vortag rutschen. + */ + private static Date localDate(int year, int month, int day) { + return Date.from(LocalDate.of(year, month, day) + .atStartOfDay(ZoneId.systemDefault()).toInstant()); + } + + /** 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; + } +} From a2e813cd40392e81ed71ce66ea4b31cb8fcd40c5 Mon Sep 17 00:00:00 2001 From: Trofeomedia <274891666+Trofeomedia@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:13:24 +0200 Subject: [PATCH 2/4] fix(h005): validate download arguments before any bank contact, use LocalDate Review round 1 on the BTD download support. - Reject a partial or reversed date range in the EbicsDownloadParams constructor, the one place every caller passes through. A half range used to be dropped when the request was built, on the launcher's legacy path without even a warning; a reversed range is schema-valid and comes back as EBICS_NO_DOWNLOAD_DATA_AVAILABLE, indistinguishable from a genuinely empty period. - Check every launcher argument before the first environment read, keystore access or bank call. It ran 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. - Carry the report period as LocalDate instead of Date. A calendar day read out of an instant depends on the machine's timezone: a UTC-midnight Date becomes the previous day west of UTC. The Date-taking overloads are kept and now document that. Adds createDateRange(LocalDate, LocalDate). - Upper-case the EBICS code list values (--service, --scope, --option, --container) so --container zip no longer aborts. Message names such as camt.053 stay as given. Tests 31 -> 36. New guards were each seen failing first. Co-Authored-By: Claude Opus 5 (1M context) --- .../org/kopi/ebics/client/EbicsClient.java | 15 +++- .../ebics/client/EbicsDownloadParams.java | 29 +++++- .../ParameterizedEbicsClientLauncher.java | 68 ++++++++++---- .../DownloadInitializationRequestElement.java | 4 +- .../org/kopi/ebics/xml/EbicsXmlFactory.java | 49 +++++++--- .../ParameterizedEbicsClientLauncherTest.java | 90 ++++++++++++++++++- ...nloadInitializationRequestElementTest.java | 33 +++++-- 7 files changed, 244 insertions(+), 44 deletions(-) diff --git a/src/main/java/org/kopi/ebics/client/EbicsClient.java b/src/main/java/org/kopi/ebics/client/EbicsClient.java index 454d0f14..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; @@ -446,10 +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, - EbicsDownloadParams.dateRangeOnly(start, end), false); + 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 index 83afad4f..9a701fec 100644 --- a/src/main/java/org/kopi/ebics/client/EbicsDownloadParams.java +++ b/src/main/java/org/kopi/ebics/client/EbicsDownloadParams.java @@ -1,6 +1,6 @@ package org.kopi.ebics.client; -import java.util.Date; +import java.time.LocalDate; /** * Service parameters for an EBICS 3.0 (H005) BTD download order. @@ -9,6 +9,15 @@ * {@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, @@ -17,11 +26,23 @@ public record EbicsDownloadParams( String messageName, String messageVersion, String containerType, - Date startDate, - Date endDate) { + 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(Date startDate, Date endDate) { + public static EbicsDownloadParams dateRangeOnly(LocalDate startDate, LocalDate endDate) { if (startDate == null && endDate == null) { return null; } diff --git a/src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java b/src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java index c15cfcfb..08d6918d 100644 --- a/src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java +++ b/src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java @@ -68,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"); @@ -162,10 +167,7 @@ public static void main(String[] args) throws Exception { user, product, orderType, - EbicsDownloadParams.dateRangeOnly( - parseDate(parsedArguments.startDate(), "--start"), - parseDate(parsedArguments.endDate(), "--end") - ), + legacyDownloadParams(parsedArguments), Boolean.parseBoolean(env("EBICS_TEST_MODE", "false")) ); } @@ -187,25 +189,50 @@ private static void printUsage() { 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. + * 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) { - // A half date range would be dropped silently further down, which is exactly how a - // catch-up run loses the days it was supposed to fetch. - if ((parsedArguments.startDate() == null) != (parsedArguments.endDate() == null)) { - throw new IllegalArgumentException( - "Options --start and --end must be given together, a single one is ignored" - + " by the bank request"); - } return new EbicsDownloadParams( - requireOption(parsedArguments.serviceName(), "--service"), - requireOption(parsedArguments.scope(), "--scope"), - parsedArguments.option(), + upperCase(requireOption(parsedArguments.serviceName(), "--service")), + upperCase(requireOption(parsedArguments.scope(), "--scope")), + upperCase(parsedArguments.option()), requireOption(parsedArguments.messageName(), "--msg-name"), requireOption(parsedArguments.messageVersion(), "--msg-version"), - requireOption(parsedArguments.containerType(), "--container"), + 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") ); @@ -223,14 +250,17 @@ private static String requireOption(String value, String option) { return normalized; } - private static Date parseDate(String value, String option) { + /** + * 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 Date.from(LocalDate.parse(normalized) - .atStartOfDay(ZoneId.systemDefault()).toInstant()); + return LocalDate.parse(normalized); } catch (DateTimeParseException e) { throw new IllegalArgumentException( "Option " + option + " expects a date as YYYY-MM-DD but was: " + normalized); diff --git a/src/main/java/org/kopi/ebics/xml/DownloadInitializationRequestElement.java b/src/main/java/org/kopi/ebics/xml/DownloadInitializationRequestElement.java index 53021288..2c102ee8 100644 --- a/src/main/java/org/kopi/ebics/xml/DownloadInitializationRequestElement.java +++ b/src/main/java/org/kopi/ebics/xml/DownloadInitializationRequestElement.java @@ -115,8 +115,8 @@ public void buildInitialization() throws EbicsException { type.setStringValue(this.getType()); StandardOrderParamsType standardOrderParamsType = EbicsXmlFactory.createStandardOrderParamsType(); - if (downloadParams != null - && downloadParams.startDate() != null && downloadParams.endDate() != null) { + // EbicsDownloadParams guarantees the range is either absent or complete. + if (downloadParams != null && downloadParams.startDate() != null) { standardOrderParamsType.setDateRange(EbicsXmlFactory.createDateRange( downloadParams.startDate(), downloadParams.endDate())); } diff --git a/src/main/java/org/kopi/ebics/xml/EbicsXmlFactory.java b/src/main/java/org/kopi/ebics/xml/EbicsXmlFactory.java index d5c33351..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,7 @@ package org.kopi.ebics.xml; +import java.time.LocalDate; import java.time.ZoneId; import java.util.Calendar; import java.util.Date; @@ -934,12 +935,15 @@ public static BTUParamsType createBTUParams(String serviceName, String scope, St * @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 start of the requested report period; may be {@code null} - * @param end the end of the requested report period; 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, Date start, Date end) { + String messageName, String messageVersion, String containerType, + LocalDate start, LocalDate end) { var type = BTDParamsType.Factory.newInstance(); var service = type.addNewService(); service.setServiceName(serviceName); @@ -972,14 +976,15 @@ public static BTDParamsType createBTDParams(String serviceName, String scope, St } /** - * Converts a date into an xs:date value without a timezone offset. Passing a - * {@link Calendar} instead 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. + * 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(Date date) { + private static DateType toXmlDate(LocalDate date) { var value = DateType.Factory.newInstance(); - value.setStringValue( - date.toInstant().atZone(ZoneId.systemDefault()).toLocalDate().toString()); + value.setStringValue(date.toString()); return value; } @@ -1034,13 +1039,29 @@ 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(); newDateRange.xsetStart(toXmlDate(start)); @@ -1049,6 +1070,14 @@ public static StandardOrderParamsType.DateRange createDateRange(Date start, Date 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 f436691f..45a0e9e3 100644 --- a/src/test/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncherTest.java +++ b/src/test/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncherTest.java @@ -5,6 +5,7 @@ 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; @@ -129,12 +130,99 @@ void rejectsHalfDateRange() { () -> ParameterizedEbicsClientLauncher.btdDownloadParams(parsed) ); assertTrue( - exception.getMessage().contains("--start and --end must be given together"), + 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 index 47f610aa..49b4c976 100644 --- a/src/test/java/org/kopi/ebics/xml/DownloadInitializationRequestElementTest.java +++ b/src/test/java/org/kopi/ebics/xml/DownloadInitializationRequestElementTest.java @@ -5,9 +5,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import java.time.LocalDate; -import java.time.ZoneId; import java.util.ArrayList; -import java.util.Date; +import java.util.TimeZone; import org.apache.xmlbeans.XmlError; import org.apache.xmlbeans.XmlOptions; import org.junit.jupiter.api.Test; @@ -45,12 +44,32 @@ void buildsBtdRequestWithSwissCamt053ServiceParams() throws Exception { } /** - * Der Kalendertag wird lokal gebildet, damit die Behauptung in jeder Zeitzone haelt. - * Fixe Epoch-Millis wuerden westlich von UTC auf den Vortag rutschen. + * 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. */ - private static Date localDate(int year, int month, int day) { - return Date.from(LocalDate.of(year, month, day) - .atStartOfDay(ZoneId.systemDefault()).toInstant()); + @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. */ From 9b3c233e14ab71c5105eb4063bcbec1f31d80543 Mon Sep 17 00:00:00 2001 From: Trofeomedia <274891666+Trofeomedia@users.noreply.github.com> Date: Tue, 25 Aug 2026 03:41:36 +0200 Subject: [PATCH 3/4] fix(config): caller properties must override the bundled defaults DefaultConfiguration takes a Properties object, stores it, and then never reads it: getString() only ever consulted the ResourceBundle, so every setting an embedder passed in was silently discarded. Three of the four affected values happened to match the bundled defaults, so nobody noticed. The fourth did not: ebics.version stayed pinned to H003 (EBICS 2.4) even when the caller asked for H005 (EBICS 3.0), so every request -- INI, HIA, HPB and BTD alike -- went out carrying Version="H003" inside an urn:org:ebics:H005 document. Neither the unit tests nor schema validation could catch this. The tests assert on the order block, not the envelope attributes, and ProtocolVersionType is defined as the pattern H\d{3} rather than an enumeration, so H003 validates happily. It only surfaces when you run the client and read what it emits. Verified against a local EBICS host: before the fix all three key-management requests carried Version="H003"; afterwards all three carry H005 and remain schema-valid. Co-Authored-By: Claude Opus 5 (1M context) --- .../ebics/session/DefaultConfiguration.java | 11 ++++ .../session/DefaultConfigurationTest.java | 61 +++++++++++++++++++ 2 files changed, 72 insertions(+) create mode 100644 src/test/java/org/kopi/ebics/session/DefaultConfigurationTest.java diff --git a/src/main/java/org/kopi/ebics/session/DefaultConfiguration.java b/src/main/java/org/kopi/ebics/session/DefaultConfiguration.java index 897c4658..fd7593b5 100644 --- a/src/main/java/org/kopi/ebics/session/DefaultConfiguration.java +++ b/src/main/java/org/kopi/ebics/session/DefaultConfiguration.java @@ -58,6 +58,17 @@ public DefaultConfiguration(File rootDir, Properties properties) { * @return the property value. */ private String getString(String key) { + // Caller-supplied properties win over the values bundled in config.properties. + // This constructor takes a Properties object precisely so that an embedder can + // configure the client; reading the bundle only made every one of those settings + // silently inert. Concretely: ebics.version stayed pinned to the bundled H003 + // (EBICS 2.4) even when the caller asked for H005 (EBICS 3.0), so every request + // went out carrying Version="H003" inside an urn:org:ebics:H005 document. + String override = properties == null ? null : properties.getProperty(key); + if (override != null) { + return override; + } + try { return bundle.getString(key); } catch(MissingResourceException e) { diff --git a/src/test/java/org/kopi/ebics/session/DefaultConfigurationTest.java b/src/test/java/org/kopi/ebics/session/DefaultConfigurationTest.java new file mode 100644 index 00000000..3469e9eb --- /dev/null +++ b/src/test/java/org/kopi/ebics/session/DefaultConfigurationTest.java @@ -0,0 +1,61 @@ +package org.kopi.ebics.session; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.io.File; +import java.util.Properties; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * The constructor takes a {@link Properties} object, so callers must be able to override + * the values bundled in config.properties. Before this test existed, getString() read the + * ResourceBundle only and silently ignored everything the caller passed in — which pinned + * every request to Version="H003" (EBICS 2.4) even inside an H005 (EBICS 3.0) namespace. + */ +class DefaultConfigurationTest { + + @Test + void callerPropertiesOverrideTheBundledProtocolVersion(@TempDir File rootDir) { + Properties properties = new Properties(); + properties.setProperty("ebics.version", "H005"); + + DefaultConfiguration configuration = new DefaultConfiguration(rootDir, properties); + + assertEquals("H005", configuration.getVersion(), + "the caller asked for EBICS 3.0; the bundled default H003 must not win"); + } + + @Test + void callerPropertiesOverrideTheBundledCryptoVersions(@TempDir File rootDir) { + Properties properties = new Properties(); + properties.setProperty("signature.version", "A006"); + properties.setProperty("authentication.version", "X003"); + properties.setProperty("encryption.version", "E003"); + + DefaultConfiguration configuration = new DefaultConfiguration(rootDir, properties); + + assertEquals("A006", configuration.getSignatureVersion()); + assertEquals("X003", configuration.getAuthenticationVersion()); + assertEquals("E003", configuration.getEncryptionVersion()); + } + + @Test + void bundledDefaultsStillApplyWhenTheCallerSaysNothing(@TempDir File rootDir) { + DefaultConfiguration configuration = new DefaultConfiguration(rootDir, new Properties()); + + assertEquals("A005", configuration.getSignatureVersion()); + assertEquals("X002", configuration.getAuthenticationVersion()); + assertEquals("E002", configuration.getEncryptionVersion()); + assertEquals("H003", configuration.getVersion(), + "unchanged upstream default — this test guards against silently changing it"); + } + + @Test + void directoryNamesStillComeFromTheBundleWhenNotOverridden(@TempDir File rootDir) { + DefaultConfiguration configuration = new DefaultConfiguration(rootDir, new Properties()); + + assertEquals("serialized", configuration.getSerializationDirectory().getName()); + assertEquals("users", configuration.getUsersDirectory().getName()); + } +} From 05ba414af85724e9e631606c3c26c8d9d2f52436 Mon Sep 17 00:00:00 2001 From: Trofeomedia <274891666+Trofeomedia@users.noreply.github.com> Date: Mon, 7 Sep 2026 14:01:55 +0200 Subject: [PATCH 4/4] feat(upload): XTC simulation upload and optional BTF message version - OrderType XTC + launcher branch: uploads the ZKB test platform's camt simulation CSV via EBICS 3.0 BTU (OTH BIL CH004TPS csv, no ES). - --msg-version is now optional for --btd: bank lists like ZKB's result archive (OTH BIL CH004TPE msc) carry no version, and an empty Version attribute fails the H005 NumStringType facet. - EbicsXmlFactory omits the MsgName Version attribute when null (BTU and BTD) instead of writing an empty string. Verified against testplattform.zkb.ch: XTC upload accepted (N003), XTD download returned the generated camt.052/053/054 for our own SCOR payments. Full test suite green (40/40). Co-Authored-By: Claude Fable 5 --- .../client/ParameterizedEbicsClientLauncher.java | 16 +++++++++++++++- .../java/org/kopi/ebics/session/OrderType.java | 1 + .../java/org/kopi/ebics/xml/EbicsXmlFactory.java | 8 ++++++-- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java b/src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java index 08d6918d..6697753a 100644 --- a/src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java +++ b/src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java @@ -213,7 +213,8 @@ static EbicsDownloadParams btdDownloadParams(ParsedArguments parsedArguments) { upperCase(requireOption(parsedArguments.scope(), "--scope")), upperCase(parsedArguments.option()), requireOption(parsedArguments.messageName(), "--msg-name"), - requireOption(parsedArguments.messageVersion(), "--msg-version"), + // Optional: bank lists like ZKB's result archive (OTH BIL CH004TPE msc) carry no version. + normalize(parsedArguments.messageVersion()), upperCase(requireOption(parsedArguments.containerType(), "--container")), parseDate(parsedArguments.startDate(), "--start"), parseDate(parsedArguments.endDate(), "--end") @@ -279,6 +280,19 @@ private static EbicsUploadParams defaultUploadParams(User user, OrderType orderT ); return new EbicsUploadParams(null, orderParams); } + if (orderType == OrderType.XTC) { + // ZKB test platform: CSV input file for camt simulation (OTH BIL CH004TPS csv). + // No message version in the bank's BTF list and no ES on a simulation input. + var orderParams = new EbicsUploadParams.OrderParams( + "OTH", + "BIL", + "CH004TPS", + "csv", + null, + false + ); + return new EbicsUploadParams(null, orderParams); + } return new EbicsUploadParams(user.getPartner().nextOrderId(), null); } diff --git a/src/main/java/org/kopi/ebics/session/OrderType.java b/src/main/java/org/kopi/ebics/session/OrderType.java index 1f107607..499a9a9b 100644 --- a/src/main/java/org/kopi/ebics/session/OrderType.java +++ b/src/main/java/org/kopi/ebics/session/OrderType.java @@ -57,6 +57,7 @@ public enum OrderType implements EbicsOrderType { XKD, XE2, XCT, + XTC, C52, C53, C54; diff --git a/src/main/java/org/kopi/ebics/xml/EbicsXmlFactory.java b/src/main/java/org/kopi/ebics/xml/EbicsXmlFactory.java index 0e479f99..ee29deb5 100644 --- a/src/main/java/org/kopi/ebics/xml/EbicsXmlFactory.java +++ b/src/main/java/org/kopi/ebics/xml/EbicsXmlFactory.java @@ -916,7 +916,9 @@ public static BTUParamsType createBTUParams(String serviceName, String scope, St msgType.setStringValue(messageName); //msgType.setFormat(messageName); - msgType.setVersion(messageVersion); + if (messageVersion != null) { + msgType.setVersion(messageVersion); + } service.setMsgName(msgType); if (signatureFlag) { var flag = type.addNewSignatureFlag(); @@ -965,7 +967,9 @@ public static BTDParamsType createBTDParams(String serviceName, String scope, St } var msgType = MessageType.Factory.newInstance(); msgType.setStringValue(messageName); - msgType.setVersion(messageVersion); + if (messageVersion != null) { + msgType.setVersion(messageVersion); + } service.setMsgName(msgType); if (start != null && end != null) { var range = type.addNewDateRange();