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]"
- + " [-- 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 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("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.
+ *
+ * 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.
+ *
+ *