Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
106 changes: 106 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
# CLAUDE.md

Guidance for working in this repository.

## What this is

`epics` is a Ruby gem implementing the [EBICS](https://www.ebics.org/) protocol
(Electronic Banking Internet Communication Standard) — a bank-communication
standard used mainly in Germany/France/Switzerland. It handles the key
initialization handshake (INI / HIA / HPB), signs requests, and exchanges
payment and statement order types with a bank's EBICS server.

- Pure library gem (no Rails). Entry point: `require "epics"`.
- Supports **EBICS 2.5 (H004, default)** and **EBICS 3.0 (H005, opt-in)**.
- License LGPL-3.0. Published to RubyGems as `epics`.

## Commands

```bash
bin/setup # bundle install
bundle exec rspec # run the full test suite
bundle exec rspec spec/client_spec.rb # single file
bundle exec rspec spec/client_spec.rb:42 # single example
bin/console # irb with the gem loaded (pry available)
```

- Ruby: developed on **3.3.7** (`.tool-versions`); CI matrix runs 3.2 / 3.3 / 3.4 / 4.0.
- `required_ruby_version >= 3.1`.
- CI is Semaphore (`.semaphore/semaphore.yml`) — just `bundle install` + `rspec`.
- No linter/formatter is configured; match surrounding style.

## Architecture

Everything is namespaced under the `Epics` module (`Ebics` is an alias).
`lib/epics.rb` is the manifest: it `require`s every file explicitly (no
autoloading) and defines protocol constants (`EBICS_PROTOCOLS`, `DEFAULT_VERSION`).

### The three layers

1. **`Epics::Client`** (`lib/epics/client.rb`) — the public API and the only
object users instantiate. Holds credentials + RSA keys, exposes one method
per order type (`STA`, `CCT`, `CDD`, `HPB`, `HTD`, …) plus convenience
wrappers (`credit`, `debit`, `statements`). Owns the Faraday `connection`
and key encryption/decryption (AES-256-CBC over a passphrase).

2. **Order classes** (`lib/epics/<order>.rb`, e.g. `sta.rb`, `cct.rb`) — one
small class per EBICS order type. Each subclasses `GenericRequest`
(downloads) or `GenericUploadRequest` (uploads) and typically only overrides
`#header` to declare `order_type`, `order_attribute`, and params. This is
the dominant pattern — to add an order type, copy the closest sibling.

3. **Request builders + middleware**
- `generic_request.rb` / `generic_upload_request.rb` / `header_request.rb` —
build the EBICS XML envelope via Nokogiri.
- `middleware/xmlsig.rb` — Faraday middleware that signs the outgoing XML
(`Epics::Signer` + `signer.rb`) before it leaves.
- `middleware/parse_ebics.rb` — Faraday middleware that wraps every response
in `Epics::Response` and raises `Epics::Error::TechnicalError` /
`BusinessError` on non-OK return codes.

### Transaction flow

`Client#download` / `#upload` (near the bottom of `client.rb`) run the
multi-step EBICS handshake: an initialization POST returns a `transaction_id`,
followed by transfer/receipt POSTs. `download_and_unzip` additionally unpacks
the ZIP payload (used by camt orders C52/C53/C54, Z-types, BKA…). Response
parsing lives in `response.rb`; RSA/key handling in `key.rb`.

### H004 vs H005 (important)

The gem defaults to H004. H005 is selected via `Epics::Client.new(..., version: :h005)`.

- `client.protocol` / `namespace` / `protocol_version` / `revision` / `h005?`
derive from the configured version.
- Under H005 there is **no flat OrderTypes list** — transactions are
**BTF services** (`btf.rb`, `btf_mapping.rb`, `Client#services`). Generic
transfer is `BTU` (upload, replaces FUL) / `BTD` (download, replaces FDL).
- Many classic order methods branch: e.g. `CCT`/`CDD`/`STA`/`C53` call
`btf_upload` / `btf_download` when `h005?`. When touching an order type,
check whether it needs an H005 branch.
- X.509 certificate support (`x_509_certificate.rb`, `letter/ini_with_certs.erb`)
is part of the H005 path.

### INI letter

`letter_renderer.rb` renders the paper initialization letter from
`lib/letter/*.erb`, localized via `lib/letter/locales/*.yml` (de/en/fr, i18n).

## Tests

- RSpec, config in `spec/spec_helper.rb` (`--require spec_helper` via `.rspec`).
- **WebMock** stubs all HTTP — tests never hit a real bank.
- Real EBICS responses live as fixtures in `spec/fixtures/xml/`; RSA keys/certs
in `spec/fixtures/*.pem` and `*.key`. Compare XML with `equivalent-xml`'s
`be_equivalent_to` matcher (namespace/whitespace-insensitive).
- H005-specific coverage is in `spec/h005_client_spec.rb`.
- Helpers/shared setup in `spec/support/`.

## Conventions

- One file per order type, named after the 3-letter EBICS code, lowercased
(`cct.rb` → `Epics::CCT`). Register new files in `lib/epics.rb`.
- Keep order classes minimal — push shared logic into the `Generic*` base
classes, not into individual order types.
- `# frozen_string_literal: true` is used on newer files; keep it when editing them.
- Bump `Epics::VERSION` (`lib/epics/version.rb`) and update `CHANGELOG.md` for releases.
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,44 @@ You can choose to configure some default values like this
e = Epics::Client.new(keys, 'passphrase', 'url', 'host', 'user', 'partner', locale: :fr, product_name: 'Mon Epic Client EBICS')
```

### EBICS 3.0 (H005)

The gem defaults to EBICS 2.5 (`H004`). To use EBICS 3.0 (`H005`), pass `version: :h005`:

```ruby
e = Epics::Client.new(keys, 'passphrase', 'url', 'host', 'user', 'partner', version: :h005)
```

In EBICS 3.0 the classic order types are replaced by the Business Transaction
Format (BTF): uploads use `BTU`, downloads use `BTD`, each described by a
`Service` instead of a `FileFormat`.

```ruby
# Upload (replaces FUL). `service` is an Epics::BTF (or a hash with the same keys).
e.BTU(document, Epics::BTF.new(service_name: 'SCT', scope: 'DE', msg_name: 'pain.001', msg_version: '03'))

# Download (replaces FDL).
e.BTD(Epics::BTF.new(service_name: 'EOP', scope: 'DE', container: 'ZIP', msg_name: 'camt.053', msg_version: '08'), from: '2026-01-01', to: '2026-01-31')
```

The common German convenience methods (`CCT`, `CDD`, `CDB`, `STA`, `C52`, `C53`,
`C54`, `VMK`) automatically route to `BTU`/`BTD` via `Epics::BtfMapping` when the
client is on `:h005`. The BTF message versions in that table are commonly-used
defaults and **are bank-specific** — verify them against your bank's BTF mapping
("Auftragsarten" annex) and override with the raw `BTU`/`BTD` API when needed.

EBICS 3.0 requires every key to be transmitted as an X.509 certificate. For
shared-key banks (e.g. German banks) the gem generates self-signed certificates
automatically for `INI`/`HIA` when no certificate is supplied.

> Note: All generated H005 requests (INI/HIA/HPB, the admin downloads, and
> BTU/BTD in every transaction phase) are validated against the official H005
> XSD schema set in the test suite, and the INI key payload against the S002
> signature schema. Full end-to-end verification against a live H005 bank
> endpoint is the remaining step. Known limitation: the `X509IssuerSerial`
> currently reports the certificate version rather than its serial number
> (pre-existing behaviour shared with the H004 X.509 path).

## Features

### Initialization
Expand Down
36 changes: 31 additions & 5 deletions lib/epics.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,33 @@
require 'faraday'
require 'securerandom'
require 'time'

module Epics
DEFAULT_PRODUCT_NAME = 'EPICS - a ruby ebics kernel'
DEFAULT_LOCALE = :de
DEFAULT_VERSION = :h004

# The electronic-signature version used for the user (bank-technical) key.
# This gem is A006-only: A006 (RSASSA-PSS, SHA-256) is signed by Epics::Key#sign
# and declared in every SignatureVersion element. A006 is valid under both
# H004 and H005 (which mandates it); the legacy A005 (RSA PKCS#1 v1.5) is not
# supported — the authentication key X002 stays PKCS#1 v1.5 on its own path.
SIGNATURE_VERSION = 'A006'

# EBICS protocol version descriptors. The gem defaults to H004 (EBICS 2.5) so
# that existing users are unaffected; H005 (EBICS 3.0) is opt-in via the
# `version:` client option.
EBICS_PROTOCOLS = {
h004: { namespace: 'urn:org:ebics:H004', version: 'H004', revision: '1' },
h005: { namespace: 'urn:org:ebics:H005', version: 'H005', revision: '1' },
}.freeze

# Raised when an order type is requested under an EBICS protocol version that
# does not support it (e.g. a classic H004 business order under H005, which
# expresses business transactions as BTF services instead).
class VersionSupportError < StandardError; end
end

require "epics/version"
require "epics/key"
require "epics/response"
Expand Down Expand Up @@ -59,15 +86,14 @@
require "epics/hia"
require "epics/ini"
require "epics/hev"
require "epics/btf"
require "epics/btf_mapping"
require "epics/btd"
require "epics/btu"
require "epics/signer"
require "epics/x_509_certificate"
require "epics/client"

I18n.load_path += Dir[File.join(File.dirname(__FILE__), 'letter/locales', '*.yml')]

module Epics
DEFAULT_PRODUCT_NAME = 'EPICS - a ruby ebics kernel'
DEFAULT_LOCALE = :de
end

Ebics = Epics
20 changes: 20 additions & 0 deletions lib/epics/btd.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true

# EBICS 3.0 (H005) generic download order. Replaces the H004 FDL order: instead
# of a FileFormat string the transfer is described by a BTF <Service>.
#
# client.BTD(Epics::BTF.new(service_name: "EOP", scope: "DE", msg_name: "camt.053", msg_name_version: "08"), from: "2026-01-01", to: "2026-01-31")
class Epics::BTD < Epics::GenericRequest
def header
client.header_request.build(
nonce: nonce,
timestamp: timestamp,
admin_order_type: 'BTD',
service: options[:service],
from: options[:from],
to: options[:to],
parameters: options[:parameters],
mutable: { TransactionPhase: 'Initialisation' }
)
end
end
57 changes: 57 additions & 0 deletions lib/epics/btf.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# frozen_string_literal: true

# Value object describing an EBICS 3.0 (H005) Business Transaction Format (BTF).
#
# A BTF replaces the H004 OrderType/FileFormat combination. It is expressed via
# the <Service> element inside BTU/BTD order params:
#
# <Service>
# <ServiceName>SCT</ServiceName>
# <ServiceOption>...</ServiceOption> (optional)
# <Scope>DE</Scope> (optional)
# <Container containerType="ZIP"/> (optional)
# <MsgName version="03">pain.001</MsgName>
# </Service>
#
# Example:
#
# Epics::BTF.new(
# service_name: "SCT",
# scope: "DE",
# msg_name: "pain.001",
# msg_name_version: "03",
# )
#
# A plain Hash with the same keys is accepted anywhere a BTF is expected.
class Epics::BTF
attr_reader :service_name, :service_option, :scope, :container,
:msg_name, :msg_name_version, :msg_variant, :msg_format

def initialize(service_name:, msg_name:, scope: nil, service_option: nil,
container: nil, msg_name_version: nil, msg_variant: nil, msg_format: nil)
@service_name = service_name
@service_option = service_option
@scope = scope
@container = container
@msg_name = msg_name
@msg_name_version = msg_name_version
@msg_variant = msg_variant
@msg_format = msg_format
end

# Normalized hash consumed by Epics::HeaderRequest#build_service.
def to_h
{
service_name: service_name,
service_option: service_option,
scope: scope,
container: container,
msg_name: {
name: msg_name,
version: msg_name_version,
variant: msg_variant,
format: msg_format,
},
}
end
end
71 changes: 71 additions & 0 deletions lib/epics/btf_mapping.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# frozen_string_literal: true

# Maps the classic H004 order codes (CCT, CDD, C53, ...) to their EBICS 3.0
# (H005) BTF Service descriptors, so the existing convenience API keeps working
# when a client is configured with `version: :h005`.
#
# This is a *starter set* for German (DE) banks. The message versions below are
# the commonly used ISO 20022 versions but ARE bank-specific — verify them
# against your bank's BTF mapping / "Auftragsarten" annex and override via the
# raw Epics::Client#BTU / #BTD API (passing an Epics::BTF) when they differ.
#
# Unmapped codes raise, pointing the caller at the raw BTU/BTD API.
module Epics::BtfMapping
# code => btf-attributes. Message versions are only pinned where we are fairly
# confident of the DE default; where omitted the <MsgName> carries no version
# attribute and the bank applies its own default. Override per call when your
# bank differs (see Epics::Client convenience methods / #BTU / #BTD).
UPLOADS = {
'CCT' => { service_name: 'SCT', scope: 'DE', msg_name: 'pain.001', msg_name_version: '03' },
'CCS' => { service_name: 'SCT', scope: 'DE', msg_name: 'pain.001', msg_name_version: '03' },
'CDD' => { service_name: 'SDD', service_option: 'COR', scope: 'DE', msg_name: 'pain.008', msg_name_version: '02' },
'CDB' => { service_name: 'SDD', service_option: 'B2B', scope: 'DE', msg_name: 'pain.008', msg_name_version: '02' },
'AZV' => { service_name: 'XCT', scope: 'DE', msg_name: 'dtazv' },
'C2S' => { service_name: 'SDD', scope: 'BIL', msg_name: 'pain.008' },
'CDS' => { service_name: 'SDD', scope: 'BIL', msg_name: 'pain.008' },
'CIP' => { service_name: 'SCI', msg_name: 'pain.001' },
'XE2' => { service_name: 'MCT', msg_name: 'pain.001' },
'XE3' => { service_name: 'SDD', msg_name: 'pain.008' },
}.freeze

DOWNLOADS = {
'STA' => { service_name: 'EOP', scope: 'DE', msg_name: 'mt940' },
'C53' => { service_name: 'EOP', scope: 'DE', container: 'ZIP', msg_name: 'camt.053', msg_name_version: '08' },
'C52' => { service_name: 'STM', scope: 'DE', container: 'ZIP', msg_name: 'camt.052', msg_name_version: '08' },
'C54' => { service_name: 'REP', scope: 'DE', container: 'ZIP', msg_name: 'camt.054', msg_name_version: '08' },
'VMK' => { service_name: 'STM', scope: 'DE', msg_name: 'mt942' },
'PSR' => { service_name: 'PSR', scope: 'DE', msg_name: 'pain.002', msg_name_version: '03' },
'Z52' => { service_name: 'STM', container: 'ZIP', msg_name: 'camt.052' },
'Z53' => { service_name: 'EOP', container: 'ZIP', msg_name: 'camt.053' },
'Z54' => { service_name: 'EOP', service_option: 'XQRR', container: 'ZIP', msg_name: 'camt.054' },
'Z01' => { service_name: 'PSR', service_option: 'CH003GEN', container: 'ZIP', msg_name: 'pain.002' },
'BKA' => { service_name: 'EOP', scope: 'DE', container: 'ZIP', msg_name: 'camt.053' },
'C5N' => { service_name: 'STM', scope: 'DE', service_option: 'SCI', container: 'ZIP', msg_name: 'camt.054' },
'CDZ' => { service_name: 'REP', scope: 'DE', service_option: 'SDD', container: 'ZIP', msg_name: 'pain.002' },
'CRZ' => { service_name: 'REP', scope: 'DE', service_option: 'SCT', container: 'ZIP', msg_name: 'pain.002' },
}.freeze

module_function

# `overrides` lets callers replace individual BTF attributes (e.g. scope,
# msg_name_version, service_option) on top of the starter-set defaults, so a bank
# that expects a different message version or scope can be served without
# dropping to the raw BTU/BTD API. nil overrides are ignored.
def upload(code, **overrides)
lookup(UPLOADS, code, overrides)
end

def download(code, **overrides)
lookup(DOWNLOADS, code, overrides)
end

def lookup(table, code, overrides = {})
attrs = table[code.to_s]
unless attrs
raise ArgumentError,
"No H005 BTF mapping for order code #{code.inspect}. Use the raw " \
"Epics::Client#BTU / #BTD API with an Epics::BTF instead."
end
Epics::BTF.new(**attrs.merge(overrides.compact))
end
end
20 changes: 20 additions & 0 deletions lib/epics/btu.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# frozen_string_literal: true

# EBICS 3.0 (H005) generic upload order. Replaces the H004 FUL order: instead of
# a FileFormat string the transfer is described by a BTF <Service>.
#
# client.BTU(document, Epics::BTF.new(service_name: "SCT", scope: "DE", msg_name: "pain.001", msg_name_version: "03"))
class Epics::BTU < Epics::GenericUploadRequest
def header
client.header_request.build(
nonce: nonce,
timestamp: timestamp,
admin_order_type: 'BTU',
service: options[:service],
signature_flag: options.fetch(:signature_flag, true),
request_eds: options[:request_eds],
parameters: options[:parameters],
mutable: { TransactionPhase: 'Initialisation' }
)
end
end
Loading