Direct debits
Build SEPA Direct Debit files (pain.008) in Rust: CORE and B2B, FRST and RCUR in one file, mandate references, amendments and the SMNDA marker.
A direct debit is money you collect. The message is pain.008, and the crate defaults to pain.008.001.08.
Every collection needs a mandate — the debtor’s authorisation — and a Creditor Identifier, which is why DirectDebitGroup::new takes one up front rather than letting you forget it.
A realistic collection run🔗
SeqTp sits at payment-group level, so first collections and recurring ones each need their own group. That is what lets one file carry a whole run.
use sepa::{
DirectDebitEntry, DirectDebitGroup, IsoDate, Pain008Builder, SequenceType,
validate_creditor_id, validate_iban,
};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let creditor = validate_iban("DE89370400440532013000")?;
let debtor = validate_iban("NL91ABNA0417164300")?;
let ci = validate_creditor_id("DE98ZZZ09999999999")?;
let xml = Pain008Builder::new("Stadtwerke GmbH", "DD-2026-07-001")
.add_group(
DirectDebitGroup::new("Stadtwerke GmbH", &creditor, &ci, IsoDate::new(2026, 7, 20)?)
.sequence_type(SequenceType::Frst)
.add_entry(DirectDebitEntry::new(
"MND-1",
"2026-06-01".parse()?,
"Neu Kunde",
debtor.clone(),
5_000,
"E2E-1",
)),
)
.add_group(
DirectDebitGroup::new("Stadtwerke GmbH", &creditor, &ci, IsoDate::new(2026, 7, 18)?)
.sequence_type(SequenceType::Rcur)
.add_entry(DirectDebitEntry::new(
"MND-2",
"2024-06-01".parse()?,
"Alt Kunde",
debtor,
7_500,
"E2E-2",
)),
)
.build()?;
assert!(xml.contains("<SeqTp>FRST</SeqTp>"));
assert!(xml.contains("<SeqTp>RCUR</SeqTp>"));
Ok(())
}| Sequence type | Meaning |
|---|---|
FRST | First collection under a newly activated mandate |
RCUR | Every subsequent collection |
FNAL | Final collection; the mandate ends after it |
OOFF | One-off — no FRST/RCUR lifecycle at all |
CORE and B2B🔗
| Scheme | Debtor | Notes |
|---|---|---|
Core | Consumers and businesses | Default. Eight-week no-reason refund for authorised collections |
B2b | Businesses only | No no-reason refund for authorised collections; the debtor’s bank verifies the mandate |
Both can appear in the same file, again as separate groups:
use sepa::pain008::DirectDebitScheme;
use sepa::{DirectDebitGroup, IsoDate, validate_creditor_id, validate_iban};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let iban = validate_iban("DE89370400440532013000")?;
let ci = validate_creditor_id("DE98ZZZ09999999999")?;
let b2b = DirectDebitGroup::new("Stadtwerke GmbH", &iban, &ci, IsoDate::new(2026, 7, 20)?)
.scheme(DirectDebitScheme::B2b);
assert_eq!(b2b.entry_count(), 0);
Ok(())
}Mandate amendments🔗
Report applicable mandate changes on the next collection. Missing or invalid mandate information can result in an MD02 rejection.
use sepa::{DirectDebitEntry, MandateAmendment, validate_iban};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let debtor = validate_iban("NL91ABNA0417164300")?;
let entry = DirectDebitEntry::new(
"MND-1",
"2024-06-01".parse()?,
"Max Mustermann",
debtor,
7_500,
"E2E-1",
)
// The debtor moved to a different account or bank.
.with_amendment(MandateAmendment::debtor_account_changed());
assert!(entry.amendment.is_some());
Ok(())
}The other cases are creditor_id_changed, creditor_name_changed, mandate_id_changed and debtor_iban_changed. The crate rejects an amendment carrying no actual change.
SMNDAmoved. Same mandate, new debtor account went inOrgnlDbtrAgtbefore November 2016 and inOrgnlDbtrAcctafter. The crate emits the current placement, and automatically switches to the old one when you select the legacy German schema, whose type system only allows the old form. An amendment cannot both state the previous IBAN and mark itSMNDA— they fill the same element, and setting both is an error rather than a silent drop.
An amendment does not reset the sequence type to FRST. Carry on with RCUR if that is where the mandate was.
See also🔗
- Recalls — stopping this file before it settles
- Reversals — sending a settled collection back
- Bank statements — matching a batch booking to this file
Full API reference on docs.rs. Found a mistake? Open an issue.