Import von Mitgliederdaten: automatisch Sepa Mandate für CiviSEPA erstellen

Unser Verein Computertruhe e. V. ist zuletzt deutlich gewachsen, sodass die Mitgliederverwaltung insbesondere der Einzug von Mitgliedsbeiträgen manuell nicht mehr effizient umsetzbar ist. Wir möchten daher CiviCRM vor allem zur Mitgliederverwaltung und SEPA-Einzug von Mitgliedsbeiträgen nutzen und haben CiviCRM, CiviSepa und CiviMembership auf unserem eigenen Server installiert.

Wir möchten nun unsere bisherigen Mitgliederdaten incl IBAN und anderen Sepa-Daten aus einer ODT Tabelle / CSV in CiviCRM importieren und automatisch SEPA-Mandate in CiviSEPA anlegen.

Wie könnten wir das umsetzen? Wer hat damit Erfahrung und möchte uns dabei helfen?

Vielen Dank!

Clemens Fiedler, Schatzmeister Computertruhe e. V

Ich habe das damals über die API gemacht und dazu ein Python-Skript geschrieben. Wichtig ist, dass du die API-Action createfull der Entität SepaMandate benutzt.

Hast du das Skript noch und könntest es hier als Beispiel teilen ?

Hallo Clemens,

ich hatte vor wenigen Montagen genau die gleiche Herausforderung als wir von GLS-Vereinsmeister zu CiviCRM gewechselt sind. Ich habe damals ein PHP-Skript (mithilfe von KI) erstellt, was gut funktioniert hat und die SepaMandate-Creatfull API nutzt.

Vorraussetzung ist ein (Linux-)Server mit SSH-Zugriff. Wenn das für euch passt, ist das Skript bestimmt hilfreich und ihr könnt es gerne für euch adaptieren. Melde dich gern!

Vielen Dank Simon!

Ja, wir wären interessiert an deinem PHP-Skript. Kannst du es hier direkt, oder als Anhang teilen?

Hallo GFF-DS, hast du das Python-Skript noch und könntest es hier teilen?

Vielen Dank!

SEPA-Import über SepaMandate createfull API

Beschreibung

Folgend mein PHP-Script für die Stapelerzeugung von SEPA-Mandaten über die createfull API zusammen mit exemplarischen Mitgliedsdaten als CSV.
Ich habe es damals für den Import der Daten von GLS-Vereinsmeister in CiviCRM genutzt.

Zu beachten:

  • Mitglieder werden hier aus historischen Gründen über die „external ID“ identifiziert
  • CiviCRM wird als Plugin in Wordpress gehostet
  • Im PHP-Script muss in Zeile 30 der Pfad zur wp-load.php definiert werden
  • Ab Zeile 200 sind die Parameter für den Import hardcoded definiert

Exemplarischer Aufruf:

sudo -u MEINBENUTZER /pfad/zur/bin/php sepaMandatImporter.php sepaimport.csv

Bei weiteren Rückfragen gerne melden.

CSV-Datei: sepaimport.csv (Mitgliederdaten)

Mitgliedsnummer;IBAN;Kontoinhaber;Zahlungsart;Jahresbeitrag;Austrittsdatum;BIC;Datum LS-Mandat;frequency_unit;cycle_day
2;;;rechnung;;;;;year;1
3;DE89 3704 0044 0532 0130 00 ;;lastschrift;35;;COBADEFFXXX;06.03.2014;year;1
6;DE89 3704 0044 0532 0130 00 ;Max Mustermann;lastschrift;20;;COBADEFFXXX;06.03.2014;year;1
7;DE89 3704 0044 0532 0130 00 ;;lastschrift;20;;COBADEFFXXX;06.03.2014;year;1


PHP-Datei: sepaMandatImporter.php (Script)

#!/usr/bin/env php
<?php
/**
 * import-sepa-mandates.php
 *
 * CLI script to import SEPA mandates into CiviCRM (WordPress).
 *
 * Usage:
 *   php import-sepa-mandates.php /pfad/zu/mandate.csv [--dry-run]
 *
 * Requirements:
 * - PHP CLI with access to your WordPress/Civi installation.
 * - Adjust WP_LOAD_PATH to your wp-load.php path.
 *
 * Notes:
 * - This script resolves the contact by external_identifier = Mitgliedsnummer.
 * - Only rows with Zahlungsart containing "SEPA" or "Lastschrift" are processed.
 * - cycle_day (Abbuchungstag) is set to 1 .
 */

if ($argc < 2) {
    echo "Usage: php {$argv[0]} /path/to/mandates.csv [--dry-run]\n";
    exit(1);
}

$csvFile = $argv[1];
$dryRun = in_array('--dry-run', $argv, true);

// === CONFIG: Pfad zur wp-load.php ===
define('WP_LOAD_PATH', '/PATH/TO/WORDPRESS/wp-load.php');
// ======================================================

if (!file_exists(WP_LOAD_PATH)) {
    fwrite(STDERR, "ERROR: wp-load.php nicht gefunden unter " . WP_LOAD_PATH . "\n");
    exit(2);
}

// Bootstrap WordPress (und damit CiviCRM, wenn Plugin aktiv)
require_once WP_LOAD_PATH;

// Try to ensure CiviCRM is initialized
try {
    if (!function_exists('civicrm_api3')) {
        // attempt to load CiviCRM if it's not already loaded
        if (defined('WP_CONTENT_DIR')) {
            // nothing more here — in most WP installs requiring wp-load is enough
        }
    }
    // ensure core config available
    if (class_exists('CRM_Core_Config')) {
        CRM_Core_Config::singleton();
    }
} catch (Exception $e) {
    fwrite(STDERR, "WARN: CiviCRM Bootstrap Problem: " . $e->getMessage() . "\n");
    // continue, will likely fail on api call if not available
}

// Log setup
$logDir = sys_get_temp_dir();
$logFile = $logDir . DIRECTORY_SEPARATOR . 'import-sepa-mandates.log';
$logHandle = fopen($logFile, 'a');
fwrite($logHandle, "[" . date('c') . "] Starting import (dry-run=" . ($dryRun ? 'yes' : 'no') . ")\n");

if (!file_exists($csvFile)) {
    fwrite(STDERR, "ERROR: CSV-Datei nicht gefunden: $csvFile\n");
    fwrite($logHandle, "ERROR: CSV-Datei nicht gefunden: $csvFile\n");
    fclose($logHandle);
    exit(3);
}

// Try to autodetect delimiter (comma or semicolon)
$firstLine = trim(file($csvFile)[0]);
$delimiter = (substr_count($firstLine, ';') > substr_count($firstLine, ',')) ? ';' : ',';

if (($handle = fopen($csvFile, 'r')) === false) {
    fwrite(STDERR, "ERROR: Konnte CSV nicht öffnen: $csvFile\n");
    fwrite($logHandle, "ERROR: Konnte CSV nicht öffnen: $csvFile\n");
    fclose($logHandle);
    exit(4);
}

// read header
$header = fgetcsv($handle, 0, $delimiter);
if ($header === false) {
    fwrite(STDERR, "ERROR: CSV hat keine Kopfzeile oder ist leer\n");
    fwrite($logHandle, "ERROR: CSV hat keine Kopfzeile oder ist leer\n");
    fclose($handle);
    fclose($logHandle);
    exit(5);
}

// normalize header keys (trim)
foreach ($header as &$h) {
    $h = trim($h);
}
unset($h);

// Helper: German date d.m.Y -> Y-m-d
function parseGermanDate($v) {
    $v = trim($v);
    if ($v === '' || $v === null) return null;
    // if already in Y-m-d
    if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $v)) return $v;
    // handle dd.mm.YYYY or d.m.YYYY
    $parts = preg_split('/[\.\/\-]/', $v);
    if (count($parts) >= 3) {
        $d = str_pad($parts[0], 2, '0', STR_PAD_LEFT);
        $m = str_pad($parts[1], 2, '0', STR_PAD_LEFT);
        $y = $parts[2];
        if (strlen($y) === 2) {
            // assume 19xx/20xx? We'll assume 19 if > 30 else 20
            $y = (intval($y) > 30) ? '19'.$y : '20'.$y;
        }
        return "$y-$m-$d";
    }
    return null;
}

// Helper: sanitize IBAN
function sanitizeIban($iban) {
    $iban = strtoupper(preg_replace('/[^A-Z0-9]/', '', $iban));
    return $iban;
}

// Process rows
$rowNo = 1;
$created = 0;
$skipped = 0;
$errors = 0;

while (($row = fgetcsv($handle, 0, $delimiter)) !== false) {
    $rowNo++;
    // combine
    $data = array_combine($header, $row);
    // normalize keys
    foreach ($data as $k => $v) {
        $data[$k] = trim($v);
    }

    // Only SEPA/Lastschrift rows
    $zahlungsart = mb_strtolower($data['Zahlungsart'] ?? '');
    if ($zahlungsart === '') {
        fwrite($logHandle, "[".date('c')."] Row $rowNo: Zahlungsart leer — skipped\n");
        $skipped++;
        continue;
    }
    if (strpos($zahlungsart, 'sepa') === false && strpos($zahlungsart, 'lastschrift') === false) {
        fwrite($logHandle, "[".date('c')."] Row $rowNo: Nicht SEPA ($zahlungsart) — skipped\n");
        $skipped++;
        continue;
    }

    $externalId = $data['Mitgliedsnummer'] ?? '';
    if ($externalId === '') {
        fwrite($logHandle, "[".date('c')."] Row $rowNo: Keine Mitgliedsnummer (external_identifier) — skipped\n");
        $skipped++;
        continue;
    }

    // find contact by external_identifier
    try {
        $contactResp = civicrm_api3('Contact', 'get', [
            'external_identifier' => $externalId,
            'sequential' => 1,
            'return' => ['id']
        ]);
    } catch (Exception $e) {
        fwrite($logHandle, "[".date('c')."] Row $rowNo: Fehler Contact.get für external_identifier={$externalId}: " . $e->getMessage() . "\n");
        $errors++;
        continue;
    }

    if (empty($contactResp['values']) || empty($contactResp['values'][0]['id'])) {
        fwrite($logHandle, "[".date('c')."] Row $rowNo: Kontakt mit external_identifier={$externalId} nicht gefunden — skipped\n");
        $skipped++;
        continue;
    }

    $contactId = $contactResp['values'][0]['id'];

    // prepare mandate data
    $ibanRaw = $data['IBAN'] ?? '';
    $iban = sanitizeIban($ibanRaw);
    $bic = strtoupper(trim($data['BIC'] ?? ''));
    $accountHolder = $data['Kontoinhaber'] ?? '';
    // if Kontoinhaber empty, attempt to use Vorname + Nachname
    if ($accountHolder === '') {
        $accountHolder = trim(($data['Vorname'] ?? '') . ' ' . ($data['Nachname'] ?? ''));
    }

    $amountRaw = $data['Jahresbeitrag'] ?? '';
    // remove commas, dots etc and normalize to float
    $amount = null;
    if ($amountRaw !== '') {
        // replace comma decimal with dot
        $amountNormalized = str_replace('.', '', $amountRaw); // remove thousands dot
        $amountNormalized = str_replace(',', '.', $amountNormalized);
        $amount = floatval($amountNormalized);
    }

    $type = strtoupper(trim($data['Type'] ?? ($data['type'] ?? 'RCUR')));
    if ($type === '') $type = 'RCUR';

    $lsDatumRaw = $data['Datum LS-Mandat'] ?? ($data['Datum LS Mandat'] ?? '');
    $startDate = parseGermanDate($lsDatumRaw);
    if ($startDate === null) {
        // Fallback: Eintrittsdatum
        $startDate = parseGermanDate($data['Eintrittsdatum'] ?? '');
    }
    if ($startDate === null) {
        fwrite($logHandle, "[".date('c')."] Row $rowNo: Kein gültiges Datum für Mandatsbeginn (Datum LS-Mandat) — skipped\n");
        $skipped++;
        continue;
    }

    // frequency unit
    $frequencyUnit = strtolower(trim($data['frequency_unit'] ?? 'year'));
    if ($frequencyUnit === '') $frequencyUnit = 'year';

    // build API params for SepaMandate.createfull
    $params = [
        'contact_id' => $contactId,
        'iban' => $iban,
        'bic' => $bic,
        'account_holder' => $accountHolder,
        'type' => $type,
		// signature Date
		'date' => $startDate,
		// Wann soll der 1. Einzug erfolgen?
        'start_date' => parseGermanDate("01.02.2026"),
        'creation_date' => parseGermanDate("13.12.2025"),
        'frequency_unit' => $frequencyUnit,
        // frequency_interval default 1 (yearly)
        'frequency_interval' => 1,
        'amount' => $amount,
        'cycle_day' => 1,
        // initial status, FRST used for first collection in some setups
        'status' => 'FRST',
        // source for traceability
        'source' => 'Importiert am DATUM aus ALTESPROGRAMM',
		// financial_type
        "financial_type_id" => 2, // <---- bitte ggf. anpassen
    ];

    // Remove empty values to avoid API errors for empty strings
    foreach ($params as $k => $v) {
        if ($v === '' || $v === null) {
            unset($params[$k]);
        }
    }

    // Dry run output
    if ($dryRun) {
        echo "[DRY] Row $rowNo -> contact_id={$contactId}, iban={$iban}, bic={$bic}, amount={$amount}, start_date={$startDate}, type={$type}\n";
        fwrite($logHandle, "[".date('c')."] [DRY] Row $rowNo prepared: " . json_encode($params) . "\n");
        $created++;
        continue;
    }

    // Call API
    try {
        $result = civicrm_api3('SepaMandate', 'createfull', $params);
        if (!empty($result['is_error'])) {
            fwrite($logHandle, "[".date('c')."] Row $rowNo: API returned error: " . json_encode($result) . "\n");
            $errors++;
            continue;
        } else {
            $mandateId = $result['id'] ?? ($result['values'][0]['id'] ?? null);
            echo "OK: Row $rowNo -> Mandat erstellt (ID: {$mandateId}) für Kontakt {$contactId}\n";
            fwrite($logHandle, "[".date('c')."] Row $rowNo -> Mandat erstellt (ID: {$mandateId}) contact_id={$contactId}\n");
			
			$contribution_recur_id = $result['values'][$result['id']]['entity_id']; 
			echo "contribution_recur_id ist $contribution_recur_id für Kontakt {$contactId}\n";
			fwrite($logHandle, "[".date('c')."] Die contribution_recur_id ist $contribution_recur_id \n");
			
			// MitgliedsID für KontaktID finden
			$memberships = civicrm_api3('Membership', 'get', [
			  'contact_id' => $contactId,
			  'status_id' => ['IN' => ['New', 'Current']],
			]);

			//  Ersten Treffer aller Mitgliedschaften nehmen
			if (!empty($memberships['values'])) {
				$first_membership = reset($memberships['values']); // reset() gibt das erste Element des Arrays
				$membership_id = $first_membership['id'];
				echo "Passende MembershipID $membership_id gefunden für Kontakt {$contactId}\n";
				// 2. Membership auf diese Contribution verweisen
				civicrm_api3('Membership', 'create', [
					'id' => $membership_id,
					'custom_6' => $contribution_recur_id,
				]);
				echo "Passende MembershipID $membership_id die wiederkehrende Zahlung $contribution_recur_id zugewiesen \n";
			} else {
				throw new Exception("Keine Membership für Contact ID $contact_id gefunden.");
			}
			
            $created++;
        }
    } catch (Exception $e) {
        fwrite($logHandle, "[".date('c')."] Row $rowNo: Ausnahme bei API-Aufruf: " . $e->getMessage() . "\n");
        echo "ERROR: Row $rowNo: " . $e->getMessage() . "\n";
        $errors++;
        continue;
    }
}

fclose($handle);
fwrite($logHandle, "[".date('c')."] Finished: created={$created}, skipped={$skipped}, errors={$errors}\n");
fclose($logHandle);

echo "Done. Log: $logFile\n";
exit(0);

Weitere Informationen

Serverumgebung:

  • PHP-Version: 8.4
  • CiviCRM-Version: 6.9
  • In Wordpress-Umgebung
  • Linux Ubuntu-Server (22.04) mit Plesk

Vielen Dank für die detaillierte Antwort und den Code. Das wird uns sehr helfen!