Skip to content

Commit 83129e0

Browse files
Trofeomediaclaude
authored andcommitted
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) <noreply@anthropic.com>
1 parent fc09f77 commit 83129e0

9 files changed

Lines changed: 584 additions & 23 deletions

‎src/main/java/org/kopi/ebics/client/EbicsClient.java‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,17 @@ public void sendFile(File file, EbicsOrderType orderType) throws Exception {
413413

414414
public void fetchFile(File file, User user, Product product, EbicsOrderType orderType,
415415
boolean isTest) throws IOException, EbicsException {
416+
fetchFile(file, user, product, orderType, null, isTest);
417+
}
418+
419+
/**
420+
* Downloads a file from the bank.
421+
*
422+
* @param downloadParams optional EBICS 3.0 service parameters and report period; with a
423+
* service name set the order is sent as a BTD business transaction format order
424+
*/
425+
public void fetchFile(File file, User user, Product product, EbicsOrderType orderType,
426+
EbicsDownloadParams downloadParams, boolean isTest) throws IOException, EbicsException {
416427
FileTransfer transferManager;
417428
EbicsSession session = createSession(user, product);
418429
session.addSessionParam("FORMAT", "pain.xxx.cfonb160.dct");
@@ -425,7 +436,7 @@ public void fetchFile(File file, User user, Product product, EbicsOrderType orde
425436
configuration.getTransferTraceDirectory(user));
426437

427438
try {
428-
transferManager.fetchFile(orderType, file);
439+
transferManager.fetchFile(orderType, downloadParams, file);
429440
} catch (NoDownloadDataAvailableException e) {
430441
// don't log this exception as an error, caller can decide how to handle
431442
throw e;
@@ -437,7 +448,8 @@ public void fetchFile(File file, User user, Product product, EbicsOrderType orde
437448

438449
public void fetchFile(File file, EbicsOrderType orderType, Date start, Date end) throws IOException,
439450
EbicsException {
440-
fetchFile(file, defaultUser, defaultProduct, orderType, false);
451+
fetchFile(file, defaultUser, defaultProduct, orderType,
452+
EbicsDownloadParams.dateRangeOnly(start, end), false);
441453
}
442454

443455
/**
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package org.kopi.ebics.client;
2+
3+
import java.util.Date;
4+
5+
/**
6+
* Service parameters for an EBICS 3.0 (H005) BTD download order.
7+
*
8+
* <p>With a {@code serviceName} set, the request is sent as {@code AdminOrderType=BTD} with a
9+
* {@code BTDOrderParams/Service} block. With {@code serviceName} left {@code null}, only the
10+
* optional date range is applied and the legacy EBICS 2.x order type is kept, so existing
11+
* callers keep their behaviour.
12+
*/
13+
public record EbicsDownloadParams(
14+
String serviceName,
15+
String scope,
16+
String option,
17+
String messageName,
18+
String messageVersion,
19+
String containerType,
20+
Date startDate,
21+
Date endDate) {
22+
23+
/** Date-range-only parameters for the legacy (non-BTD) download path. */
24+
public static EbicsDownloadParams dateRangeOnly(Date startDate, Date endDate) {
25+
if (startDate == null && endDate == null) {
26+
return null;
27+
}
28+
return new EbicsDownloadParams(null, null, null, null, null, null, startDate, endDate);
29+
}
30+
31+
/** Whether these parameters describe an EBICS 3.0 BTD business transaction format order. */
32+
public boolean isBtd() {
33+
return serviceName != null;
34+
}
35+
}

‎src/main/java/org/kopi/ebics/client/FileTransfer.java‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,9 +173,27 @@ public void sendFile(ContentFactory factory,
173173
public void fetchFile(EbicsOrderType orderType,
174174
File outputFile)
175175
throws IOException, EbicsException
176+
{
177+
fetchFile(orderType, null, outputFile);
178+
}
179+
180+
/**
181+
* Fetches a file of the given order type from the bank.
182+
* This type of transfer will run until everything is processed.
183+
* No transaction recovery is possible.
184+
* @param orderType type of file to fetch
185+
* @param downloadParams optional EBICS 3.0 service parameters and report period
186+
* @param outputFile where to put the data
187+
* @throws IOException communication error
188+
* @throws EbicsException server generated error
189+
*/
190+
public void fetchFile(EbicsOrderType orderType,
191+
EbicsDownloadParams downloadParams,
192+
File outputFile)
193+
throws IOException, EbicsException
176194
{
177195
var sender = new HttpRequestSender(session);
178-
var initializer = new DownloadInitializationRequestElement(session, orderType);
196+
var initializer = new DownloadInitializationRequestElement(session, orderType, downloadParams);
179197
initializer.build();
180198
initializer.validate();
181199

‎src/main/java/org/kopi/ebics/client/ParameterizedEbicsClientLauncher.java‎

Lines changed: 134 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,18 @@
2020

2121
import java.io.File;
2222
import java.net.URL;
23+
import java.time.LocalDate;
24+
import java.time.ZoneId;
25+
import java.time.format.DateTimeParseException;
26+
import java.util.Date;
27+
import java.util.LinkedHashMap;
2328
import java.util.LinkedHashSet;
2429
import java.util.Locale;
30+
import java.util.Map;
2531
import java.util.Properties;
2632
import java.util.Set;
2733
import org.kopi.ebics.interfaces.EbicsBank;
34+
import org.kopi.ebics.interfaces.EbicsOrderType;
2835
import org.kopi.ebics.interfaces.EbicsPartner;
2936
import org.kopi.ebics.interfaces.PasswordCallback;
3037
import org.kopi.ebics.session.DefaultConfiguration;
@@ -41,9 +48,16 @@ public final class ParameterizedEbicsClientLauncher {
4148
"--ini",
4249
"--hia",
4350
"--hpb",
44-
"--help"
51+
"--help",
52+
"--btd"
4553
);
4654

55+
/**
56+
* EBICS 3.0 business transaction downloads always use the admin order type {@code BTD}; the
57+
* business order is carried by the service parameters instead of the 3-letter code.
58+
*/
59+
private static final EbicsOrderType BTD_ORDER_TYPE = () -> "BTD";
60+
4761
private ParameterizedEbicsClientLauncher() {
4862
}
4963

@@ -117,6 +131,20 @@ public static void main(String[] args) throws Exception {
117131
client.sendHPBRequest(user, product);
118132
}
119133

134+
if (parsedArguments.hasFlag("--btd")) {
135+
EbicsDownloadParams downloadParams = btdDownloadParams(parsedArguments);
136+
client.fetchFile(
137+
new File(requireOutputPath(parsedArguments)),
138+
user,
139+
product,
140+
BTD_ORDER_TYPE,
141+
downloadParams,
142+
Boolean.parseBoolean(env("EBICS_TEST_MODE", "false"))
143+
);
144+
client.quit();
145+
return;
146+
}
147+
120148
String orderFlag = parsedArguments.firstOrderFlag();
121149
if (orderFlag != null) {
122150
OrderType orderType = OrderType.valueOf(orderFlag.substring(2).toUpperCase(Locale.ROOT));
@@ -129,16 +157,15 @@ public static void main(String[] args) throws Exception {
129157
defaultUploadParams(user, orderType)
130158
);
131159
} else if (parsedArguments.outputPath() != null) {
132-
if (parsedArguments.startDate() != null || parsedArguments.endDate() != null) {
133-
System.err.println(
134-
"Date range arguments are ignored in parameterized mode for this order type."
135-
);
136-
}
137160
client.fetchFile(
138161
new File(parsedArguments.outputPath()),
139162
user,
140163
product,
141164
orderType,
165+
EbicsDownloadParams.dateRangeOnly(
166+
parseDate(parsedArguments.startDate(), "--start"),
167+
parseDate(parsedArguments.endDate(), "--end")
168+
),
142169
Boolean.parseBoolean(env("EBICS_TEST_MODE", "false"))
143170
);
144171
}
@@ -149,12 +176,67 @@ public static void main(String[] args) throws Exception {
149176

150177
private static void printUsage() {
151178
String usage = "Usage: ParameterizedEbicsClientLauncher [--create] [--ini] [--hia] [--hpb]"
152-
+ " [--<order>] [-i inputFile] [-o outputFile]\n"
179+
+ " [--<order>] [-i inputFile] [-o outputFile] [-s start] [-e end]\n"
180+
+ "EBICS 3.0 download: --btd --service <NAME> --scope <CC> --msg-name <name>"
181+
+ " --msg-version <vv> --container <XML|ZIP|SVC>"
182+
+ " [--option <OPT>] [-s YYYY-MM-DD] [-e YYYY-MM-DD] -o <file>\n"
183+
+ " e.g. --btd --service EOP --scope CH --msg-name camt.053 --msg-version 08"
184+
+ " --container ZIP -o statement.zip\n"
153185
+ "Required environment variables: EBICS_PASSWORD, EBICS_USER_ID, EBICS_PARTNER_ID,"
154186
+ " EBICS_HOST_ID, EBICS_BANK_URL";
155187
System.out.println(usage);
156188
}
157189

190+
/**
191+
* Builds the EBICS 3.0 service parameters for {@code --btd}. Fails fast on a missing mandatory
192+
* value, so a half-filled order is never sent to the bank.
193+
*/
194+
static EbicsDownloadParams btdDownloadParams(ParsedArguments parsedArguments) {
195+
// A half date range would be dropped silently further down, which is exactly how a
196+
// catch-up run loses the days it was supposed to fetch.
197+
if ((parsedArguments.startDate() == null) != (parsedArguments.endDate() == null)) {
198+
throw new IllegalArgumentException(
199+
"Options --start and --end must be given together, a single one is ignored"
200+
+ " by the bank request");
201+
}
202+
return new EbicsDownloadParams(
203+
requireOption(parsedArguments.serviceName(), "--service"),
204+
requireOption(parsedArguments.scope(), "--scope"),
205+
parsedArguments.option(),
206+
requireOption(parsedArguments.messageName(), "--msg-name"),
207+
requireOption(parsedArguments.messageVersion(), "--msg-version"),
208+
requireOption(parsedArguments.containerType(), "--container"),
209+
parseDate(parsedArguments.startDate(), "--start"),
210+
parseDate(parsedArguments.endDate(), "--end")
211+
);
212+
}
213+
214+
static String requireOutputPath(ParsedArguments parsedArguments) {
215+
return requireOption(parsedArguments.outputPath(), "-o");
216+
}
217+
218+
private static String requireOption(String value, String option) {
219+
String normalized = normalize(value);
220+
if (normalized == null) {
221+
throw new IllegalArgumentException("Missing required option " + option + " for --btd");
222+
}
223+
return normalized;
224+
}
225+
226+
private static Date parseDate(String value, String option) {
227+
String normalized = normalize(value);
228+
if (normalized == null) {
229+
return null;
230+
}
231+
try {
232+
return Date.from(LocalDate.parse(normalized)
233+
.atStartOfDay(ZoneId.systemDefault()).toInstant());
234+
} catch (DateTimeParseException e) {
235+
throw new IllegalArgumentException(
236+
"Option " + option + " expects a date as YYYY-MM-DD but was: " + normalized);
237+
}
238+
}
239+
158240
private static EbicsUploadParams defaultUploadParams(User user, OrderType orderType) {
159241
if (orderType == OrderType.XE2) {
160242
var orderParams = new EbicsUploadParams.OrderParams(
@@ -290,27 +372,41 @@ static String normalize(String value) {
290372

291373
static final class ParsedArguments {
292374
private final Set<String> flags = new LinkedHashSet<>();
375+
private final Map<String, String> values;
293376
private final String inputPath;
294377
private final String outputPath;
295378
private final String startDate;
296379
private final String endDate;
297380

298381
private ParsedArguments(
299382
Set<String> flags,
383+
Map<String, String> values,
300384
String inputPath,
301385
String outputPath,
302386
String startDate,
303387
String endDate
304388
) {
305389
this.flags.addAll(flags);
390+
this.values = Map.copyOf(values);
306391
this.inputPath = inputPath;
307392
this.outputPath = outputPath;
308393
this.startDate = startDate;
309394
this.endDate = endDate;
310395
}
311396

397+
/** Value options of the EBICS 3.0 service block; each consumes the following argument. */
398+
private static final Set<String> VALUE_OPTIONS = Set.of(
399+
"--service",
400+
"--scope",
401+
"--option",
402+
"--msg-name",
403+
"--msg-version",
404+
"--container"
405+
);
406+
312407
static ParsedArguments parse(String[] args) {
313408
Set<String> flags = new LinkedHashSet<>();
409+
Map<String, String> values = new LinkedHashMap<>();
314410
String inputPath = null;
315411
String outputPath = null;
316412
String startDate = null;
@@ -337,12 +433,17 @@ static ParsedArguments parse(String[] args) {
337433
endDate = requireValue(args, ++index, arg);
338434
continue;
339435
}
436+
String lowered = arg.toLowerCase(Locale.ROOT);
437+
if (VALUE_OPTIONS.contains(lowered)) {
438+
values.put(lowered, requireValue(args, ++index, arg));
439+
continue;
440+
}
340441
if (arg.startsWith("--")) {
341-
flags.add(arg.toLowerCase(Locale.ROOT));
442+
flags.add(lowered);
342443
}
343444
}
344445
}
345-
return new ParsedArguments(flags, inputPath, outputPath, startDate, endDate);
446+
return new ParsedArguments(flags, values, inputPath, outputPath, startDate, endDate);
346447
}
347448

348449
private static String requireValue(String[] args, int index, String option) {
@@ -393,5 +494,29 @@ String startDate() {
393494
String endDate() {
394495
return endDate;
395496
}
497+
498+
String serviceName() {
499+
return values.get("--service");
500+
}
501+
502+
String scope() {
503+
return values.get("--scope");
504+
}
505+
506+
String option() {
507+
return values.get("--option");
508+
}
509+
510+
String messageName() {
511+
return values.get("--msg-name");
512+
}
513+
514+
String messageVersion() {
515+
return values.get("--msg-version");
516+
}
517+
518+
String containerType() {
519+
return values.get("--container");
520+
}
396521
}
397522
}

0 commit comments

Comments
 (0)