sepa

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.

On this page

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 typeMeaning
FRSTFirst collection under a newly activated mandate
RCUREvery subsequent collection
FNALFinal collection; the mandate ends after it
OOFFOne-off — no FRST/RCUR lifecycle at all

CORE and B2B🔗

SchemeDebtorNotes
CoreConsumers and businessesDefault. Eight-week no-reason refund for authorised collections
B2bBusinesses onlyNo 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.

SMNDA moved. Same mandate, new debtor account went in OrgnlDbtrAgt before November 2016 and in OrgnlDbtrAcct after. 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 it SMNDA — 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🔗


Full API reference on docs.rs. Found a mistake? Open an issue.