gnucash future: Multiple changes pushed

John Ralls jralls at code.gnucash.org
Sat Jul 18 19:17:57 EDT 2026


Updated	 via  https://github.com/Gnucash/gnucash/commit/0ec51ed3 (commit)
	 via  https://github.com/Gnucash/gnucash/commit/4ccc95dd (commit)
	from  https://github.com/Gnucash/gnucash/commit/0e53aff8 (commit)



commit 0ec51ed3eede927e953e05065eaaac6f5da66b15
Merge: 0e53aff804 4ccc95ddf2
Author: John Ralls <jralls at ceridwen.us>
Date:   Sat Jul 18 16:17:26 2026 -0700

    Merge copystring's 'aqbanking-standing-orders' into future.


commit 4ccc95ddf2a4cadcf4195fc0f02f3ca533079b01
Author: copystring <1298861+copystring at users.noreply.github.com>
Date:   Sat Jul 11 12:17:47 2026 +0200

    AqBanking: Import standing orders
    
    Fetch supported SEPA standing orders and synchronize them to externally managed scheduled transactions. Preserve local scheduling preferences and account assignments while updating bank-owned fields.
    
    Add deterministic AqBanking context tests for account matching, updates, removals, validation, and unknown counterparties.

diff --git a/gnucash/import-export/aqb/CMakeLists.txt b/gnucash/import-export/aqb/CMakeLists.txt
index 662638a849..a4e97fbade 100644
--- a/gnucash/import-export/aqb/CMakeLists.txt
+++ b/gnucash/import-export/aqb/CMakeLists.txt
@@ -9,6 +9,7 @@ set (aqbanking_SOURCES
   dialog-ab-select-imexporter.c
   assistant-ab-initial.c
   gnc-ab-getbalance.c
+  gnc-ab-standing-orders.c
   gnc-ab-gettrans.c
   gnc-ab-kvp.c
   gnc-ab-transfer.c
@@ -29,6 +30,7 @@ set (aqbanking_noinst_HEADERS
   dialog-ab-select-imexporter.h
   assistant-ab-initial.h
   gnc-ab-getbalance.h
+  gnc-ab-standing-orders.h
   gnc-ab-gettrans.h
   gnc-ab-kvp.h
   gnc-ab-transfer.h
diff --git a/gnucash/import-export/aqb/gnc-ab-gettrans.c b/gnucash/import-export/aqb/gnc-ab-gettrans.c
index edf0760771..54a63e53d5 100644
--- a/gnucash/import-export/aqb/gnc-ab-gettrans.c
+++ b/gnucash/import-export/aqb/gnc-ab-gettrans.c
@@ -33,11 +33,15 @@
 
 #include <glib/gi18n.h>
 #include <aqbanking/banking.h>
-# include <aqbanking/types/transaction.h>
+#include <aqbanking/types/transaction.h>
+#include <aqbanking/types/imexporter_accountinfo.h>
+#include <aqbanking/types/imexporter_context.h>
 #include "Account.h"
 #include "dialog-ab-daterange.h"
+#include "dialog-sx-editor.h"
 #include "gnc-ab-gettrans.h"
 #include "gnc-ab-kvp.h"
+#include "gnc-ab-standing-orders.h"
 #include "gnc-gwen-gui.h"
 #include "gnc-ui.h"
 
@@ -235,3 +239,110 @@ cleanup:
         GWEN_Time_free(from_date);
     gnc_AB_BANKING_fini(api);
 }
+
+void
+gnc_ab_getstandingorders(GtkWidget *parent, Account *gnc_acc)
+{
+    AB_BANKING *api;
+    GNC_AB_ACCOUNT_SPEC *ab_acc;
+    GNC_AB_JOB *job = NULL;
+    GNC_AB_JOB_LIST2 *job_list = NULL;
+    GncGWENGui *gui = NULL;
+    AB_IMEXPORTER_CONTEXT *context = NULL;
+    GNC_AB_JOB_STATUS job_status;
+    GList *node;
+    GncABStandingOrderSyncResult sync_result;
+    const gchar *summary_heading;
+    g_return_if_fail(parent && gnc_acc);
+
+    api = gnc_AB_BANKING_new();
+    if (!api)
+    {
+        g_warning("gnc_ab_getstandingorders: Couldn't get AqBanking API");
+        return;
+    }
+
+    ab_acc = gnc_ab_get_ab_account(api, gnc_acc);
+    if (!ab_acc)
+    {
+        g_warning("gnc_ab_getstandingorders: No AqBanking account found");
+        gnc_error_dialog (GTK_WINDOW (parent), _("No valid online banking account assigned."));
+        goto cleanup;
+    }
+
+    if (!AB_AccountSpec_GetTransactionLimitsForCommand(
+            ab_acc, AB_Transaction_CommandSepaGetStandingOrders))
+    {
+        g_warning("gnc_ab_getstandingorders: JobSepaGetStandingOrders not available for this account");
+        gnc_error_dialog (
+            GTK_WINDOW (parent),
+            _("Online action \"Get Standing Orders\" not available for this account."));
+        goto cleanup;
+    }
+
+    job = AB_Transaction_new();
+    AB_Transaction_SetCommand(job, AB_Transaction_CommandSepaGetStandingOrders);
+    AB_Transaction_SetUniqueAccountId(job, AB_AccountSpec_GetUniqueId(ab_acc));
+
+    job_list = AB_Transaction_List2_new();
+    AB_Transaction_List2_PushBack(job_list, job);
+
+    gui = gnc_GWEN_Gui_get(parent);
+    if (!gui)
+    {
+        g_warning("gnc_ab_getstandingorders: Couldn't initialize Gwenhywfar GUI");
+        gnc_error_dialog (GTK_WINDOW (parent),
+                          _("Could not initialize the online banking user interface."));
+        goto cleanup;
+    }
+
+    context = AB_ImExporterContext_new();
+    AB_Banking_SendCommands(api, job_list, context);
+
+    job_status = AB_Transaction_GetStatus(job);
+    if (job_status != AB_Transaction_StatusAccepted
+            && job_status != AB_Transaction_StatusPending)
+    {
+        g_warning("gnc_ab_getstandingorders: Error on executing job");
+        gnc_error_dialog (GTK_WINDOW (parent),
+                          _("Error on executing job.\n\nStatus: %s (%d)"),
+                          AB_Transaction_Status_toString(job_status),
+                          job_status);
+        goto cleanup;
+    }
+
+    sync_result = gnc_ab_import_standing_orders (context, gnc_acc);
+    summary_heading = sync_result.received == 0
+        ? _("The bank returned no standing orders.")
+        : _("Standing order retrieval completed.");
+    gnc_info_dialog (
+        GTK_WINDOW (parent),
+        _("%s\n\n"
+          "Received: %u\n"
+          "Created: %u\n"
+          "Updated: %u\n"
+          "Disabled: %u\n"
+          "Skipped: %u"),
+        summary_heading,
+        sync_result.received,
+        sync_result.created,
+        sync_result.updated,
+        sync_result.disabled,
+        sync_result.skipped);
+
+    for (node = sync_result.to_edit; node; node = node->next)
+        gnc_ui_scheduled_xaction_editor_dialog_create (
+            GTK_WINDOW (parent), GNC_SCHEDXACTION (node->data), FALSE);
+    g_list_free (sync_result.to_edit);
+
+cleanup:
+    if (context)
+        AB_ImExporterContext_free(context);
+    if (gui)
+        gnc_GWEN_Gui_release(gui);
+    if (job_list)
+        AB_Transaction_List2_free(job_list);
+    if (job)
+        AB_Transaction_free(job);
+    gnc_AB_BANKING_fini(api);
+}
diff --git a/gnucash/import-export/aqb/gnc-ab-gettrans.h b/gnucash/import-export/aqb/gnc-ab-gettrans.h
index dda6f7bdae..585afb6f99 100644
--- a/gnucash/import-export/aqb/gnc-ab-gettrans.h
+++ b/gnucash/import-export/aqb/gnc-ab-gettrans.h
@@ -34,17 +34,27 @@
 #define GNC_AB_GETTRANS_H
 
 #include <glib.h>
+#include <gtk/gtk.h>
+#include "Account.h"
 
 G_BEGIN_DECLS
 
 /**
  * Execute a GetTransactions job.
  *
- * @param parent Widget to use as parent, may be NULL
+ * @param parent Widget to use as parent
  * @param gnc_acc GnuCash account to fetch transactions for
  */
 void gnc_ab_gettrans(GtkWidget *parent, Account *gnc_acc);
 
+/**
+ * Execute a SepaGetStandingOrders job.
+ *
+ * @param parent Widget to use as parent
+ * @param gnc_acc GnuCash account to fetch standing orders for
+ */
+void gnc_ab_getstandingorders(GtkWidget *parent, Account *gnc_acc);
+
 G_END_DECLS
 
 #endif /* GNC_AB_GETTRANS_H */
diff --git a/gnucash/import-export/aqb/gnc-ab-kvp.c b/gnucash/import-export/aqb/gnc-ab-kvp.c
index ed0a1c5b52..436797808a 100644
--- a/gnucash/import-export/aqb/gnc-ab-kvp.c
+++ b/gnucash/import-export/aqb/gnc-ab-kvp.c
@@ -31,6 +31,11 @@
 #include "gnc-ui-util.h"
 
 #include "gnc-ab-kvp.h"
+#include "qofinstance-p.h"
+
+#define AB_STANDING_ORDER_KVP_ROOT "aqbanking-standing-order"
+#define AB_STANDING_ORDER_KVP_ID "id"
+#define AB_STANDING_ORDER_KVP_ACCOUNT_GUID "account-guid"
 
 /* This static indicates the debugging module that this .o belongs to.  */
 G_GNUC_UNUSED static QofLogModule log_module = G_LOG_DOMAIN;
@@ -115,3 +120,61 @@ gnc_ab_set_account_trans_retrieval(Account *a, time64 time)
 		      NULL);
     xaccAccountCommitEdit(a);
 }
+
+static gchar *
+get_standing_order_string (const SchedXaction *sx, const gchar *slot)
+{
+    GValue value = G_VALUE_INIT;
+    gchar *result = NULL;
+
+    g_return_val_if_fail (GNC_IS_SX (sx), NULL);
+
+    qof_instance_get_kvp (QOF_INSTANCE (sx), &value, 2,
+                          AB_STANDING_ORDER_KVP_ROOT, slot);
+    if (G_VALUE_HOLDS_STRING (&value))
+        result = g_value_dup_string (&value);
+    if (G_IS_VALUE (&value))
+        g_value_unset (&value);
+
+    return result;
+}
+
+static void
+set_standing_order_string (SchedXaction *sx, const gchar *slot,
+                           const gchar *string)
+{
+    GValue value = G_VALUE_INIT;
+
+    g_value_init (&value, G_TYPE_STRING);
+    g_value_set_string (&value, string ? string : "");
+    qof_instance_set_kvp (QOF_INSTANCE (sx), &value, 2,
+                          AB_STANDING_ORDER_KVP_ROOT, slot);
+    g_value_unset (&value);
+}
+
+gchar *
+gnc_ab_get_standing_order_id (const SchedXaction *sx)
+{
+    return get_standing_order_string (sx, AB_STANDING_ORDER_KVP_ID);
+}
+
+gchar *
+gnc_ab_get_standing_order_account_guid (const SchedXaction *sx)
+{
+    return get_standing_order_string (sx, AB_STANDING_ORDER_KVP_ACCOUNT_GUID);
+}
+
+void
+gnc_ab_set_standing_order_metadata (SchedXaction *sx,
+                                    const gchar *id,
+                                    const gchar *account_guid)
+{
+    g_return_if_fail (GNC_IS_SX (sx));
+
+    gnc_sx_begin_edit (sx);
+    set_standing_order_string (sx, AB_STANDING_ORDER_KVP_ID, id);
+    set_standing_order_string (sx, AB_STANDING_ORDER_KVP_ACCOUNT_GUID,
+                               account_guid);
+    qof_instance_set_dirty (QOF_INSTANCE (sx));
+    gnc_sx_commit_edit (sx);
+}
diff --git a/gnucash/import-export/aqb/gnc-ab-kvp.h b/gnucash/import-export/aqb/gnc-ab-kvp.h
index 44fe818d7f..889f092f6a 100644
--- a/gnucash/import-export/aqb/gnc-ab-kvp.h
+++ b/gnucash/import-export/aqb/gnc-ab-kvp.h
@@ -36,6 +36,7 @@
 #include <glib.h>
 
 #include "Account.h"
+#include "SchedXaction.h"
 
 G_BEGIN_DECLS
 
@@ -112,4 +113,19 @@ void gnc_ab_set_account_trans_retrieval(Account *a, time64 time);
 
 /** @} */
 
+/** Return the AqBanking standing-order identifier stored on @a sx.
+ * The caller owns the returned string.
+ */
+gchar *gnc_ab_get_standing_order_id (const SchedXaction *sx);
+
+/** Return the source-account GUID stored on an imported standing order.
+ * The caller owns the returned string.
+ */
+gchar *gnc_ab_get_standing_order_account_guid (const SchedXaction *sx);
+
+/** Store the AqBanking identity and account metadata for @a sx. */
+void gnc_ab_set_standing_order_metadata (SchedXaction *sx,
+                                         const gchar *id,
+                                         const gchar *account_guid);
+
 #endif /* GNC_AB_KVP_H */
diff --git a/gnucash/import-export/aqb/gnc-ab-standing-orders.c b/gnucash/import-export/aqb/gnc-ab-standing-orders.c
new file mode 100644
index 0000000000..7d93b1bbea
--- /dev/null
+++ b/gnucash/import-export/aqb/gnc-ab-standing-orders.c
@@ -0,0 +1,846 @@
+/*
+ * gnc-ab-standing-orders.c -- AqBanking standing-order synchronization
+ *
+ * The original author places this work in the public domain, free
+ * for anyone to use as they please.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
+ */
+
+#include <config.h>
+
+#include "gnc-ab-standing-orders.h"
+
+#include <glib/gi18n.h>
+#include <aqbanking/types/imexporter_accountinfo.h>
+#include <aqbanking/types/imexporter_context.h>
+#include <aqbanking/types/transaction.h>
+#include <aqbanking/types/value.h>
+#include <gwenhywfar/gwendate.h>
+
+#include "Account.h"
+#include "Recurrence.h"
+#include "SchedXaction.h"
+#include "Split.h"
+#include "SX-book.h"
+#include "Transaction.h"
+#include "dialog-sx-editor.h"
+#include "gnc-ab-kvp.h"
+#include "gnc-ab-utils.h"
+#include "gnc-date.h"
+#include "gnc-numeric.h"
+#include "gnc-prefs.h"
+#include "gnc-ui-util.h"
+
+#define MAX_IBAN_LENGTH 34
+
+static gboolean
+standing_order_normalize_iban (const gchar *iban,
+                               gchar normalized[MAX_IBAN_LENGTH + 1])
+{
+    gsize len = 0;
+
+    g_return_val_if_fail (normalized, FALSE);
+    normalized[0] = '\0';
+
+    if (!iban || !*iban)
+        return FALSE;
+
+    while (*iban)
+    {
+        if (g_ascii_isalnum (*iban))
+        {
+            if (len >= MAX_IBAN_LENGTH)
+                return FALSE;
+            normalized[len++] = g_ascii_toupper (*iban);
+        }
+        iban++;
+    }
+
+    normalized[len] = '\0';
+    return len > 0;
+}
+
+static const gchar *
+standing_order_skip_leading_zeroes (const gchar *value)
+{
+    if (!value)
+        return NULL;
+
+    while (*value == '0')
+        value++;
+
+    return value;
+}
+
+static gboolean
+standing_order_equal_account_number (const gchar *left, const gchar *right)
+{
+    left = standing_order_skip_leading_zeroes (left);
+    right = standing_order_skip_leading_zeroes (right);
+
+    return left && right && *left && *right && g_strcmp0 (left, right) == 0;
+}
+
+static gboolean
+standing_order_get_de_account_from_iban (const gchar *iban,
+                                         gchar **bankcode,
+                                         gchar **account_number)
+{
+    gchar normalized_iban[MAX_IBAN_LENGTH + 1];
+
+    g_return_val_if_fail (bankcode && account_number, FALSE);
+
+    *bankcode = NULL;
+    *account_number = NULL;
+    if (!standing_order_normalize_iban (iban, normalized_iban))
+        return FALSE;
+
+    if (strlen (normalized_iban) == 22
+        && g_str_has_prefix (normalized_iban, "DE"))
+    {
+        *bankcode = g_strndup (normalized_iban + 4, 8);
+        *account_number = g_strdup (normalized_iban + 12);
+    }
+
+    return *bankcode && *account_number;
+}
+
+static gboolean
+standing_order_account_can_have_bank_details (Account *account)
+{
+    switch (xaccAccountGetType (account))
+    {
+    case ACCT_TYPE_BANK:
+    case ACCT_TYPE_ASSET:
+    case ACCT_TYPE_CREDIT:
+    case ACCT_TYPE_LIABILITY:
+        return TRUE;
+    default:
+        return FALSE;
+    }
+}
+
+typedef struct
+{
+    const gchar *bankcode;
+    const gchar *account_number;
+    const gchar *online_id;
+    Account *matched_account;
+} StandingOrderAbAccountLookup;
+
+static void
+standing_order_find_by_ab_account_cb (Account *account, gpointer user_data)
+{
+    StandingOrderAbAccountLookup *lookup = user_data;
+    const gchar *account_bankcode;
+    const gchar *account_accountid;
+    const gchar *account_online_id;
+
+    if (lookup->matched_account
+        || !standing_order_account_can_have_bank_details (account))
+        return;
+
+    account_bankcode = gnc_ab_get_account_bankcode (account);
+    account_accountid = gnc_ab_get_account_accountid (account);
+    account_online_id = xaccAccountGetOnlineID (account);
+
+    if (account_bankcode && *account_bankcode
+        && g_strcmp0 (account_bankcode, lookup->bankcode) == 0
+        && standing_order_equal_account_number (account_accountid,
+                                                lookup->account_number))
+    {
+        lookup->matched_account = account;
+        return;
+    }
+
+    if (lookup->online_id && account_online_id && *account_online_id
+        && g_strcmp0 (account_online_id, lookup->online_id) == 0)
+        lookup->matched_account = account;
+}
+
+static Account *
+standing_order_find_account_by_ab_account (Account *base_account,
+                                           const gchar *bankcode,
+                                           const gchar *account_number)
+{
+    StandingOrderAbAccountLookup lookup = { bankcode, account_number, NULL, NULL };
+
+    if (!base_account || !bankcode || !*bankcode
+        || !account_number || !*account_number)
+        return NULL;
+
+    lookup.online_id = gnc_ab_create_online_id (bankcode, account_number);
+    gnc_account_foreach_descendant (gnc_account_get_root (base_account),
+                                    standing_order_find_by_ab_account_cb,
+                                    &lookup);
+    g_free ((gchar *)lookup.online_id);
+
+    return lookup.matched_account;
+}
+
+static Account *
+standing_order_find_account (Account *base_account, const gchar *iban,
+                             const gchar *bankcode,
+                             const gchar *account_number)
+{
+    Account *account;
+    gchar *iban_bankcode = NULL;
+    gchar *iban_account_number = NULL;
+
+    account = standing_order_find_account_by_ab_account (base_account,
+                                                         bankcode,
+                                                         account_number);
+    if (account)
+        return account;
+
+    if (standing_order_get_de_account_from_iban (iban, &iban_bankcode,
+                                                 &iban_account_number))
+    {
+        account = standing_order_find_account_by_ab_account (base_account,
+                                                             iban_bankcode,
+                                                             iban_account_number);
+        g_free (iban_bankcode);
+        g_free (iban_account_number);
+        if (account)
+            return account;
+    }
+
+    return NULL;
+}
+
+static GDate
+standing_order_gdate_from_gwen_date (const GWEN_DATE *date)
+{
+    GDate gdate;
+
+    g_date_clear (&gdate, 1);
+    if (date)
+        g_date_set_dmy (&gdate, GWEN_Date_GetDay (date),
+                        (GDateMonth)GWEN_Date_GetMonth (date),
+                        GWEN_Date_GetYear (date));
+    return gdate;
+}
+
+static PeriodType
+standing_order_period_type (const AB_TRANSACTION *ab_trans)
+{
+    switch (AB_Transaction_GetPeriod (ab_trans))
+    {
+    case AB_Transaction_PeriodWeekly:
+        return PERIOD_WEEK;
+    case AB_Transaction_PeriodMonthly:
+        return PERIOD_MONTH;
+    case AB_Transaction_PeriodNone:
+    case AB_Transaction_PeriodUnknown:
+    default:
+        return PERIOD_INVALID;
+    }
+}
+
+static GDate
+standing_order_start_date (const AB_TRANSACTION *ab_trans)
+{
+    GDate start = standing_order_gdate_from_gwen_date (
+        AB_Transaction_GetNextDate (ab_trans));
+    GDate today;
+    GDate reference;
+    GDate next;
+    Recurrence recurrence;
+    PeriodType period_type;
+    guint cycle;
+
+    if (!g_date_valid (&start))
+        start = standing_order_gdate_from_gwen_date (AB_Transaction_GetFirstDate (ab_trans));
+
+    if (!g_date_valid (&start))
+        return start;
+
+    gnc_gdate_set_today (&today);
+    if (g_date_compare (&start, &today) >= 0)
+        return start;
+
+    period_type = standing_order_period_type (ab_trans);
+    if (period_type == PERIOD_INVALID)
+        return start;
+
+    cycle = AB_Transaction_GetCycle (ab_trans);
+    recurrenceSet (&recurrence, cycle ? cycle : 1, period_type, &start,
+                   WEEKEND_ADJ_NONE);
+    reference = today;
+    g_date_subtract_days (&reference, 1);
+    recurrenceNextInstance (&recurrence, &reference, &next);
+
+    return g_date_valid (&next) ? next : start;
+}
+
+static GList *
+standing_order_schedule (const AB_TRANSACTION *ab_trans, const GDate *start)
+{
+    Recurrence *recurrence = g_new0 (Recurrence, 1);
+    guint cycle = AB_Transaction_GetCycle (ab_trans);
+    PeriodType period_type = standing_order_period_type (ab_trans);
+
+    if (period_type == PERIOD_INVALID)
+    {
+        g_free (recurrence);
+        return NULL;
+    }
+
+    recurrenceSet (recurrence, cycle ? cycle : 1, period_type, start,
+                   WEEKEND_ADJ_NONE);
+    return g_list_append (NULL, recurrence);
+}
+
+static gchar *
+standing_order_key (const AB_TRANSACTION *ab_trans)
+{
+    const gchar *fiid = AB_Transaction_GetFiId (ab_trans);
+    guint32 unique_id = AB_Transaction_GetUniqueId (ab_trans);
+    const AB_VALUE *value = AB_Transaction_GetValue (ab_trans);
+    gint64 value_num = value ? AB_Value_Num (value) : 0;
+    gint64 value_denom = value ? AB_Value_Denom (value) : 1;
+
+    if (fiid && *fiid)
+        return g_strdup_printf ("fiid:%s", fiid);
+    if (unique_id != 0)
+        return g_strdup_printf ("uid:%" G_GUINT32_FORMAT, unique_id);
+
+    return g_strdup_printf ("fingerprint:%s:%s:%d:%" G_GUINT32_FORMAT
+                            ":%" G_GUINT32_FORMAT ":%" G_GINT64_FORMAT
+                            "/%" G_GINT64_FORMAT ":%s",
+                            AB_Transaction_GetLocalIban (ab_trans) ? AB_Transaction_GetLocalIban (ab_trans) : "",
+                            AB_Transaction_GetRemoteIban (ab_trans) ? AB_Transaction_GetRemoteIban (ab_trans) : "",
+                            AB_Transaction_GetPeriod (ab_trans),
+                            AB_Transaction_GetCycle (ab_trans),
+                            AB_Transaction_GetExecutionDay (ab_trans),
+                            value_num,
+                            value_denom,
+                            AB_Transaction_GetPurpose (ab_trans) ? AB_Transaction_GetPurpose (ab_trans) : "");
+}
+
+static gchar *
+standing_order_purpose (const AB_TRANSACTION *ab_trans)
+{
+    gchar *purpose = gnc_ab_get_purpose (ab_trans, FALSE);
+
+    if (purpose)
+    {
+        g_strstrip (purpose);
+        if (*purpose)
+            return purpose;
+    }
+
+    g_free (purpose);
+    return NULL;
+}
+
+static gchar *
+standing_order_name (const AB_TRANSACTION *ab_trans)
+{
+    gchar *purpose = standing_order_purpose (ab_trans);
+    gchar *name;
+
+    if (purpose)
+        name = g_strdup_printf (_("Online Banking: %s"), purpose);
+    else
+        name = g_strdup_printf (_("Online Banking: %s"), _("Bank Standing Order"));
+
+    g_free (purpose);
+    return name;
+}
+static SchedXaction *
+standing_order_find_sx (QofBook *book, const gchar *key,
+                        const gchar *account_guid)
+{
+    SchedXactions *sxes = gnc_book_get_schedxactions (book);
+    GList *node;
+
+    for (node = sxes ? sxes->sx_list : NULL; node; node = node->next)
+    {
+        SchedXaction *sx = GNC_SCHEDXACTION (node->data);
+        gchar *stored_key = gnc_ab_get_standing_order_id (sx);
+        gchar *stored_account_guid =
+            gnc_ab_get_standing_order_account_guid (sx);
+
+        if (stored_key && g_strcmp0 (stored_key, key) == 0
+            && (!stored_account_guid
+                || g_strcmp0 (stored_account_guid, account_guid) == 0))
+        {
+            g_free (stored_key);
+            g_free (stored_account_guid);
+            return sx;
+        }
+        g_free (stored_key);
+        g_free (stored_account_guid);
+    }
+
+    return NULL;
+}
+
+static gnc_numeric
+standing_order_amount (const AB_TRANSACTION *ab_trans)
+{
+    const AB_VALUE *ab_value = AB_Transaction_GetValue (ab_trans);
+    gnc_numeric amount;
+
+    if (!ab_value)
+        return gnc_numeric_zero ();
+
+    amount = gnc_numeric_create (AB_Value_Num (ab_value), AB_Value_Denom (ab_value));
+    return gnc_numeric_abs (amount);
+}
+
+static gchar *
+standing_order_formula (gnc_numeric amount)
+{
+    return g_strdup (xaccPrintAmount (amount, gnc_default_print_info (FALSE)));
+}
+
+static void
+standing_order_append_template_split (QofBook *book, Transaction *trans,
+                              Account *template_account, Account *account,
+                              gnc_numeric debit_numeric,
+                              gnc_numeric credit_numeric,
+                              const gchar *debit_formula,
+                              const gchar *credit_formula,
+                              const gchar *memo)
+{
+    Split *split = xaccMallocSplit (book);
+    const GncGUID *account_guid = xaccAccountGetGUID (account);
+
+    xaccSplitSetMemo (split, memo);
+    xaccAccountInsertSplit (template_account, split);
+    qof_instance_set (QOF_INSTANCE (split),
+                      "sx-credit-formula", credit_formula ? credit_formula : "",
+                      "sx-credit-numeric", &credit_numeric,
+                      "sx-debit-formula", debit_formula ? debit_formula : "",
+                      "sx-debit-numeric", &debit_numeric,
+                      "sx-account", account_guid,
+                      NULL);
+    xaccTransAppendSplit (trans, split);
+}
+
+static Account *
+standing_order_get_or_make_imbalance_account (QofBook *book,
+                                              gnc_commodity *commodity)
+{
+    Account *root;
+    Account *account;
+    gchar *name;
+
+    g_return_val_if_fail (book && commodity, NULL);
+
+    root = gnc_book_get_root_account (book);
+    name = g_strconcat (_("Imbalance"), "-",
+                        gnc_commodity_get_mnemonic (commodity), NULL);
+    account = gnc_account_lookup_by_name (root, name);
+    if (!account)
+    {
+        account = xaccMallocAccount (book);
+        xaccAccountBeginEdit (account);
+        xaccAccountSetName (account, name);
+        xaccAccountSetCommodity (account, commodity);
+        xaccAccountSetType (account, ACCT_TYPE_BANK);
+        gnc_account_append_child (root, account);
+        xaccAccountCommitEdit (account);
+    }
+    g_free (name);
+
+    return account;
+}
+
+static void
+standing_order_set_template (SchedXaction *sx,
+                             const AB_TRANSACTION *ab_trans,
+                             Account *local_account)
+{
+    QofBook *book;
+    Transaction *trans;
+    Account *remote_account;
+    Account *counter_account;
+    gnc_commodity *commodity;
+    gnc_numeric amount;
+    gnc_numeric zero = gnc_numeric_zero ();
+    gchar *amount_str;
+    gchar *description;
+    gchar *memo;
+
+    book = gnc_account_get_book (local_account);
+    commodity = xaccAccountGetCommodity (local_account);
+    trans = xaccMallocTransaction (book);
+    remote_account = standing_order_find_account (local_account,
+                                                   AB_Transaction_GetRemoteIban (ab_trans),
+                                                   AB_Transaction_GetRemoteBankCode (ab_trans),
+                                                   AB_Transaction_GetRemoteAccountNumber (ab_trans));
+
+    counter_account = remote_account;
+    if (!counter_account || counter_account == local_account)
+    {
+        if (counter_account == local_account)
+            g_warning ("Standing order remote account matches the local GnuCash account; using an imbalance split.");
+        counter_account = standing_order_get_or_make_imbalance_account (book, commodity);
+    }
+
+    amount = standing_order_amount (ab_trans);
+    amount_str = standing_order_formula (amount);
+    description = standing_order_purpose (ab_trans);
+    if (!description)
+        description = g_strdup ("");
+    memo = g_strdup ("");
+
+    xaccTransBeginEdit (trans);
+    xaccTransSetDescription (trans, description);
+    xaccTransSetDatePostedSecsNormalized (trans, gnc_time (NULL));
+    xaccTransSetCurrency (trans, commodity);
+
+    standing_order_append_template_split (book, trans, sx->template_acct, local_account,
+                                          zero, amount, NULL, amount_str, memo);
+    standing_order_append_template_split (book, trans, sx->template_acct, counter_account,
+                                          amount, zero, amount_str, NULL, memo);
+
+    xaccTransCommitEdit (trans);
+
+    g_free (amount_str);
+    g_free (description);
+    g_free (memo);
+}
+
+static Split *
+standing_order_find_local_split (SchedXaction *sx, Account *local_account)
+{
+    const GncGUID *local_guid = xaccAccountGetGUID (local_account);
+    GList *splits = xaccSchedXactionGetSplits (sx);
+    GList *node;
+    Split *local_split = NULL;
+
+    for (node = splits; node; node = node->next)
+    {
+        Split *split = node->data;
+        GncGUID *split_guid = NULL;
+
+        qof_instance_get (QOF_INSTANCE (split),
+                          "sx-account", &split_guid,
+                          NULL);
+        if (split_guid && guid_equal (split_guid, local_guid))
+            local_split = split;
+        guid_free (split_guid);
+
+        if (local_split)
+            break;
+    }
+    g_list_free (splits);
+
+    return local_split;
+}
+
+static gboolean
+standing_order_update_local_formula (SchedXaction *sx,
+                                     const AB_TRANSACTION *ab_trans,
+                                     Account *local_account,
+                                     gboolean *updated)
+{
+    Split *local_split = standing_order_find_local_split (sx, local_account);
+    Transaction *trans;
+    gnc_numeric amount = standing_order_amount (ab_trans);
+    gnc_numeric zero = gnc_numeric_zero ();
+    gchar *amount_str = standing_order_formula (amount);
+    gchar *credit_formula = NULL;
+    gchar *debit_formula = NULL;
+    gboolean needs_edit;
+
+    *updated = FALSE;
+    if (!local_split)
+    {
+        g_warning ("Standing order has no template split for its source account.");
+        g_free (amount_str);
+        return TRUE;
+    }
+
+    qof_instance_get (QOF_INSTANCE (local_split),
+                      "sx-credit-formula", &credit_formula,
+                      "sx-debit-formula", &debit_formula,
+                      NULL);
+    needs_edit = g_strcmp0 (credit_formula, amount_str) != 0
+                 || (debit_formula && *debit_formula);
+    g_free (credit_formula);
+    g_free (debit_formula);
+
+    if (!needs_edit)
+    {
+        g_free (amount_str);
+        return FALSE;
+    }
+
+    trans = xaccSplitGetParent (local_split);
+    if (trans)
+    {
+        xaccTransBeginEdit (trans);
+        qof_instance_set (QOF_INSTANCE (local_split),
+                          "sx-credit-formula", amount_str,
+                          "sx-credit-numeric", &amount,
+                          "sx-debit-formula", "",
+                          "sx-debit-numeric", &zero,
+                          NULL);
+        xaccTransCommitEdit (trans);
+        *updated = TRUE;
+    }
+
+    g_free (amount_str);
+    return TRUE;
+}
+
+static void
+standing_order_apply_sx_defaults (SchedXaction *sx)
+{
+    gboolean autocreate;
+    gboolean notify;
+    gint days_in_advance;
+
+    autocreate = gnc_prefs_get_bool (GNC_PREFS_GROUP_SXED,
+                                     GNC_PREF_CREATE_AUTO);
+    notify = gnc_prefs_get_bool (GNC_PREFS_GROUP_SXED,
+                                 GNC_PREF_NOTIFY);
+    xaccSchedXactionSetAutoCreate (sx, autocreate, autocreate && notify);
+
+    days_in_advance = gnc_prefs_get_float (GNC_PREFS_GROUP_SXED,
+                                           GNC_PREF_CREATE_DAYS);
+    xaccSchedXactionSetAdvanceCreation (sx, days_in_advance);
+
+    days_in_advance = gnc_prefs_get_float (GNC_PREFS_GROUP_SXED,
+                                           GNC_PREF_REMIND_DAYS);
+    xaccSchedXactionSetAdvanceReminder (sx, days_in_advance);
+}
+
+static gchar *
+standing_order_snapshot_key (const gchar *account_guid, const gchar *key)
+{
+    return g_strconcat (account_guid, ":", key, NULL);
+}
+
+static void
+standing_order_sync (QofBook *book, Account *local_account,
+                     const AB_TRANSACTION *ab_trans, GHashTable *seen,
+                     GncABStandingOrderSyncResult *result)
+{
+    SchedXaction *sx;
+    GDate start_date;
+    GDate end_date;
+    GList *old_schedule;
+    GList *schedule;
+    gchar account_guid[GUID_ENCODING_LENGTH + 1];
+    gchar *key;
+    gchar *name;
+    gchar *snapshot_key;
+
+    if (!ab_trans || AB_Transaction_GetType (ab_trans) != AB_Transaction_TypeStandingOrder)
+        return;
+
+    key = standing_order_key (ab_trans);
+    guid_to_string_buff (xaccAccountGetGUID (local_account), account_guid);
+    snapshot_key = standing_order_snapshot_key (account_guid, key);
+    g_hash_table_add (seen, snapshot_key);
+
+    if (gnc_numeric_zero_p (standing_order_amount (ab_trans)))
+    {
+        g_warning ("Skipping standing order without an amount.");
+        result->skipped++;
+        g_free (key);
+        return;
+    }
+
+    sx = standing_order_find_sx (book, key, account_guid);
+    if (sx)
+    {
+        gboolean formula_updated = FALSE;
+        gboolean needs_edit;
+        gboolean was_enabled = xaccSchedXactionGetEnabled (sx);
+
+        needs_edit = standing_order_update_local_formula (sx, ab_trans,
+                                                          local_account,
+                                                          &formula_updated);
+        if (!was_enabled)
+            xaccSchedXactionSetEnabled (sx, TRUE);
+        gnc_ab_set_standing_order_metadata (sx, key, account_guid);
+
+        if (formula_updated || !was_enabled)
+            result->updated++;
+        if (needs_edit && !g_list_find (result->to_edit, sx))
+            result->to_edit = g_list_prepend (result->to_edit, sx);
+
+        g_free (key);
+        return;
+    }
+
+    start_date = standing_order_start_date (ab_trans);
+    if (!g_date_valid (&start_date))
+    {
+        g_warning ("Skipping standing order without a valid start date.");
+        result->skipped++;
+        g_free (key);
+        return;
+    }
+
+    schedule = standing_order_schedule (ab_trans, &start_date);
+    if (!schedule)
+    {
+        g_warning ("Skipping standing order with an unsupported period.");
+        result->skipped++;
+        g_free (key);
+        return;
+    }
+
+    sx = xaccSchedXactionMalloc (book);
+    name = standing_order_name (ab_trans);
+    end_date = standing_order_gdate_from_gwen_date (AB_Transaction_GetLastDate (ab_trans));
+
+    gnc_sx_begin_edit (sx);
+    xaccSchedXactionSetName (sx, name);
+    old_schedule = gnc_sx_get_schedule (sx);
+    gnc_sx_set_schedule (sx, schedule);
+    recurrenceListFree (&old_schedule);
+    xaccSchedXactionSetStartDate (sx, &start_date);
+    xaccSchedXactionSetEndDate (sx, &end_date);
+    xaccSchedXactionSetNumOccur (sx, 0);
+    xaccSchedXactionSetEnabled (sx, TRUE);
+    gnc_sx_commit_edit (sx);
+
+    gnc_ab_set_standing_order_metadata (sx, key, account_guid);
+    standing_order_apply_sx_defaults (sx);
+    gnc_sx_set_instance_count (sx, 1);
+    standing_order_set_template (sx, ab_trans, local_account);
+    gnc_sxes_add_sx (gnc_book_get_schedxactions (book), sx);
+
+    result->created++;
+    result->to_edit = g_list_prepend (result->to_edit, sx);
+
+    g_free (name);
+    g_free (key);
+}
+
+static Account *
+standing_order_account_for_account_info (AB_IMEXPORTER_ACCOUNTINFO *acc_info,
+                                         Account *default_acc)
+{
+    Account *account;
+    const gchar *iban = AB_ImExporterAccountInfo_GetIban (acc_info);
+    const gchar *bankcode = AB_ImExporterAccountInfo_GetBankCode (acc_info);
+    const gchar *account_number =
+        AB_ImExporterAccountInfo_GetAccountNumber (acc_info);
+
+    account = standing_order_find_account (default_acc, iban, bankcode,
+                                           account_number);
+    if (!account && (!iban || !*iban) && (!bankcode || !*bankcode)
+        && (!account_number || !*account_number))
+        account = default_acc;
+
+    return account;
+}
+
+static void
+standing_order_disable_missing (QofBook *book, GHashTable *imported_accounts,
+                                GHashTable *seen,
+                                GncABStandingOrderSyncResult *result)
+{
+    SchedXactions *sxes = gnc_book_get_schedxactions (book);
+    GList *node;
+
+    for (node = sxes ? sxes->sx_list : NULL; node; node = node->next)
+    {
+        SchedXaction *sx = GNC_SCHEDXACTION (node->data);
+        gchar *key = gnc_ab_get_standing_order_id (sx);
+        gchar *account_guid = gnc_ab_get_standing_order_account_guid (sx);
+        gchar *snapshot_key;
+
+        if (!key || !account_guid
+            || !g_hash_table_contains (imported_accounts, account_guid))
+        {
+            g_free (key);
+            g_free (account_guid);
+            continue;
+        }
+
+        snapshot_key = standing_order_snapshot_key (account_guid, key);
+        if (!g_hash_table_contains (seen, snapshot_key)
+            && xaccSchedXactionGetEnabled (sx))
+        {
+            xaccSchedXactionSetEnabled (sx, FALSE);
+            result->disabled++;
+        }
+
+        g_free (snapshot_key);
+        g_free (key);
+        g_free (account_guid);
+    }
+}
+
+GncABStandingOrderSyncResult
+gnc_ab_import_standing_orders (AB_IMEXPORTER_CONTEXT *context,
+                               Account *default_acc)
+{
+    AB_IMEXPORTER_ACCOUNTINFO_LIST *account_info_list;
+    AB_IMEXPORTER_ACCOUNTINFO *acc_info;
+    GncABStandingOrderSyncResult result = { 0 };
+    GHashTable *imported_accounts;
+    GHashTable *seen;
+    QofBook *book;
+
+    g_return_val_if_fail (context && default_acc, result);
+
+    book = gnc_account_get_book (default_acc);
+    imported_accounts = g_hash_table_new_full (g_str_hash, g_str_equal,
+                                               g_free, NULL);
+    seen = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, NULL);
+    account_info_list = AB_ImExporterContext_GetAccountInfoList (context);
+
+    for (acc_info = AB_ImExporterAccountInfo_List_First (account_info_list);
+         acc_info;
+         acc_info = AB_ImExporterAccountInfo_List_Next (acc_info))
+    {
+        AB_TRANSACTION_LIST *transactions;
+        AB_TRANSACTION *ab_trans;
+        Account *local_account;
+        gchar local_account_guid[GUID_ENCODING_LENGTH + 1];
+        gboolean warned_unknown_source = FALSE;
+
+        transactions = AB_ImExporterAccountInfo_GetTransactionList (acc_info);
+        local_account = standing_order_account_for_account_info (acc_info, default_acc);
+        if (local_account)
+        {
+            guid_to_string_buff (xaccAccountGetGUID (local_account),
+                                 local_account_guid);
+            g_hash_table_add (imported_accounts, g_strdup (local_account_guid));
+        }
+
+        for (ab_trans = AB_Transaction_List_FindFirstByType (transactions,
+                                                             AB_Transaction_TypeStandingOrder,
+                                                             0);
+             ab_trans;
+             ab_trans = AB_Transaction_List_FindNextByType (ab_trans,
+                                                            AB_Transaction_TypeStandingOrder,
+                                                            0))
+        {
+            result.received++;
+            if (!local_account)
+            {
+                if (!warned_unknown_source)
+                {
+                    g_warning ("Skipping standing-order data for an unknown source account.");
+                    warned_unknown_source = TRUE;
+                }
+                result.skipped++;
+                continue;
+            }
+            standing_order_sync (book, local_account, ab_trans, seen, &result);
+        }
+    }
+
+    standing_order_disable_missing (book, imported_accounts, seen, &result);
+    g_hash_table_destroy (seen);
+    g_hash_table_destroy (imported_accounts);
+    result.to_edit = g_list_reverse (result.to_edit);
+
+    return result;
+}
diff --git a/gnucash/import-export/aqb/gnc-ab-standing-orders.h b/gnucash/import-export/aqb/gnc-ab-standing-orders.h
new file mode 100644
index 0000000000..a0acf9cd08
--- /dev/null
+++ b/gnucash/import-export/aqb/gnc-ab-standing-orders.h
@@ -0,0 +1,43 @@
+/*
+ * gnc-ab-standing-orders.h -- AqBanking standing-order synchronization
+ * Copyright 2026 copystring
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+#ifndef GNC_AB_STANDING_ORDERS_H
+#define GNC_AB_STANDING_ORDERS_H
+
+#include <glib.h>
+
+#include "Account.h"
+
+typedef struct AB_IMEXPORTER_CONTEXT AB_IMEXPORTER_CONTEXT;
+
+typedef struct
+{
+    guint received;
+    guint created;
+    guint updated;
+    guint disabled;
+    guint skipped;
+    /* Borrowed SchedXaction pointers; the caller owns only the list. */
+    GList *to_edit;
+} GncABStandingOrderSyncResult;
+
+G_BEGIN_DECLS
+
+/** Synchronize a complete AqBanking standing-order snapshot into a book.
+ *
+ * This entry point has no GUI or live-bank dependency so that callers and
+ * tests can provide a synthetic AqBanking response.
+ */
+GncABStandingOrderSyncResult gnc_ab_import_standing_orders (
+    AB_IMEXPORTER_CONTEXT *context, Account *default_acc);
+
+G_END_DECLS
+
+#endif /* GNC_AB_STANDING_ORDERS_H */
diff --git a/gnucash/import-export/aqb/gnc-plugin-aqbanking.c b/gnucash/import-export/aqb/gnc-plugin-aqbanking.c
index 1f81e69a27..9aaa922060 100644
--- a/gnucash/import-export/aqb/gnc-plugin-aqbanking.c
+++ b/gnucash/import-export/aqb/gnc-plugin-aqbanking.c
@@ -68,6 +68,9 @@ static Account *main_window_to_account(GncMainWindow *window);
 static void gnc_plugin_ab_cmd_setup (GSimpleAction *simple, GVariant *parameter, gpointer user_data);
 static void gnc_plugin_ab_cmd_get_balance (GSimpleAction *simple, GVariant *parameter, gpointer user_data);
 static void gnc_plugin_ab_cmd_get_transactions (GSimpleAction *simple, GVariant *parameter, gpointer user_data);
+static void gnc_plugin_ab_cmd_get_standing_orders (GSimpleAction *simple,
+                                                   GVariant *parameter,
+                                                   gpointer user_data);
 static void gnc_plugin_ab_cmd_issue_sepatransaction (GSimpleAction *simple, GVariant *parameter, gpointer user_data);
 static void gnc_plugin_ab_cmd_issue_sepainternaltransaction (GSimpleAction *simple, GVariant *parameter, gpointer user_data);
 static void gnc_plugin_ab_cmd_issue_inttransaction (GSimpleAction *simple, GVariant *parameter, gpointer user_data);
@@ -86,6 +89,7 @@ static GActionEntry gnc_plugin_actions [] =
     { "ABSetupAction", gnc_plugin_ab_cmd_setup, NULL, NULL, NULL },
     { "ABGetBalanceAction", gnc_plugin_ab_cmd_get_balance, NULL, NULL, NULL },
     { "ABGetTransAction", gnc_plugin_ab_cmd_get_transactions, NULL, NULL, NULL },
+    { "ABGetStandingOrdersAction", gnc_plugin_ab_cmd_get_standing_orders, NULL, NULL, NULL },
     { "ABIssueSepaTransAction", gnc_plugin_ab_cmd_issue_sepatransaction, NULL, NULL, NULL },
     { "ABIssueSepaIntTransAction", gnc_plugin_ab_cmd_issue_sepainternaltransaction, NULL, NULL, NULL },
     { "ABIssueIntTransAction", gnc_plugin_ab_cmd_issue_inttransaction, NULL, NULL, NULL },
@@ -109,6 +113,7 @@ static const gchar *need_account_actions[] =
 {
     "ABGetBalanceAction",
     "ABGetTransAction",
+    "ABGetStandingOrdersAction",
     "ABIssueSepaTransAction",
 #if (AQBANKING_VERSION_INT >= 60400)
     "ABIssueSepaIntTransAction",
@@ -488,6 +493,27 @@ gnc_plugin_ab_cmd_get_transactions (GSimpleAction *simple,
     LEAVE(" ");
 }
 
+static void
+gnc_plugin_ab_cmd_get_standing_orders (GSimpleAction *simple,
+                                       GVariant *parameter,
+                                       gpointer user_data)
+{
+    GncMainWindowActionData *data = user_data;
+    Account *account;
+
+    ENTER("action %p, main window data %p", simple, data);
+    account = main_window_to_account(data->window);
+    if (account == NULL)
+    {
+        PINFO("No AqBanking account selected");
+        LEAVE("no account");
+        return;
+    }
+    gnc_ab_getstandingorders(GTK_WIDGET(data->window), account);
+
+    LEAVE(" ");
+}
+
 static void
 gnc_plugin_ab_cmd_issue_sepatransaction (GSimpleAction *simple,
                                          GVariant *parameter,
diff --git a/gnucash/import-export/aqb/gnc-plugin-aqbanking.ui b/gnucash/import-export/aqb/gnc-plugin-aqbanking.ui
index 5adc4e454a..d709750eea 100644
--- a/gnucash/import-export/aqb/gnc-plugin-aqbanking.ui
+++ b/gnucash/import-export/aqb/gnc-plugin-aqbanking.ui
@@ -24,6 +24,11 @@
           <attribute name="action">gnc-plugin-aqbanking-actions.ABGetTransAction</attribute>
           <attribute name="tooltip" translatable="yes">Get the transactions online through Online Banking</attribute>
         </item>
+        <item>
+          <attribute name="label" translatable="yes">Get Standing _Orders…</attribute>
+          <attribute name="action">gnc-plugin-aqbanking-actions.ABGetStandingOrdersAction</attribute>
+          <attribute name="tooltip" translatable="yes">Get standing orders online through Online Banking</attribute>
+        </item>
       </section>
       <section>
         <item>
diff --git a/gnucash/import-export/aqb/test/CMakeLists.txt b/gnucash/import-export/aqb/test/CMakeLists.txt
index a5e7b81b6e..caab60894d 100644
--- a/gnucash/import-export/aqb/test/CMakeLists.txt
+++ b/gnucash/import-export/aqb/test/CMakeLists.txt
@@ -1,8 +1,9 @@
-set(test_aqb_SOURCES test-aqb.c test-kvp.c)
+set(test_aqb_SOURCES test-aqb.c test-kvp.c test-standing-orders.c)
 
 set(test_aqb_INCLUDE_DIRS
   ${CMAKE_BINARY_DIR}/common
   ${CMAKE_SOURCE_DIR}/gnucash/import-export/aqb
+  ${CMAKE_SOURCE_DIR}/common/test-core
   ${CMAKE_SOURCE_DIR}/common
   ${CMAKE_SOURCE_DIR}/gnucash/import-export
   ${CMAKE_SOURCE_DIR}/gnucash/gnome
diff --git a/gnucash/import-export/aqb/test/test-aqb.c b/gnucash/import-export/aqb/test/test-aqb.c
index 656f75a3d8..39a186e011 100644
--- a/gnucash/import-export/aqb/test/test-aqb.c
+++ b/gnucash/import-export/aqb/test/test-aqb.c
@@ -30,6 +30,7 @@
 #include <TransLog.h>
 
 extern void test_qofsession_aqb_kvp( void );
+extern void test_suite_aqb_standing_orders (void);
 
 int
 main (int   argc,
@@ -52,6 +53,7 @@ main (int   argc,
      * details. Unfortunately, GLib-Testing doesn't provide the automatic
      * registration features of more sophisticated frameworks. */
     g_test_add_func ("/import-export/aqb/kvp", test_qofsession_aqb_kvp);
+    test_suite_aqb_standing_orders ();
 
     return g_test_run();
 }
diff --git a/gnucash/import-export/aqb/test/test-standing-orders.c b/gnucash/import-export/aqb/test/test-standing-orders.c
new file mode 100644
index 0000000000..d1e2af8ed5
--- /dev/null
+++ b/gnucash/import-export/aqb/test/test-standing-orders.c
@@ -0,0 +1,850 @@
+/*
+ * test-standing-orders.c -- AqBanking standing-order synchronization tests
+ * Copyright 2026 copystring
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ */
+
+#include <config.h>
+
+#include <glib.h>
+#include <aqbanking/types/imexporter_accountinfo.h>
+#include <aqbanking/types/imexporter_context.h>
+#include <aqbanking/types/transaction.h>
+#include <aqbanking/types/value.h>
+#include <gwenhywfar/gwendate.h>
+
+#include <unittest-support.h>
+#include "Account.h"
+#include "SX-book.h"
+#include "SchedXaction.h"
+#include "Split.h"
+#include "Transaction.h"
+#include "gnc-ab-kvp.h"
+#include "gnc-ab-standing-orders.h"
+#include "gnc-commodity.h"
+
+#define LOCAL_IBAN "DE92500105175447461406"
+#define LOCAL_BANK_CODE "50010517"
+#define LOCAL_ACCOUNT_NUMBER "5447461406"
+#define EXTERNAL_IBAN "GB82WEST12345698765432"
+#define INTERNAL_IBAN "FR7630006000011234567890189"
+#define LINKED_IBAN "DE24500105175804977567"
+#define LINKED_BANK_CODE "50010517"
+#define LINKED_ACCOUNT_NUMBER "5804977567"
+
+void test_suite_aqb_standing_orders (void);
+
+static const gchar *suitename = "/import-export/aqb/standing-orders";
+
+typedef struct
+{
+    QofBook *book;
+    gnc_commodity *currency;
+    Account *local_account;
+    Account *internal_account;
+    Account *linked_account;
+    Account *category_account;
+} StandingOrderFixture;
+
+static Account *
+create_account (StandingOrderFixture *fixture, const gchar *name,
+                GNCAccountType type)
+{
+    Account *account = xaccMallocAccount (fixture->book);
+
+    xaccAccountBeginEdit (account);
+    xaccAccountSetName (account, name);
+    xaccAccountSetType (account, type);
+    xaccAccountSetCommodity (account, fixture->currency);
+    gnc_account_append_child (gnc_book_get_root_account (fixture->book),
+                              account);
+    xaccAccountCommitEdit (account);
+
+    return account;
+}
+
+static void
+setup (StandingOrderFixture *fixture, gconstpointer user_data)
+{
+    gnc_commodity_table *table;
+
+    fixture->book = qof_book_new ();
+    table = gnc_commodity_table_get_table (fixture->book);
+    fixture->currency = gnc_commodity_table_lookup (
+        table, GNC_COMMODITY_NS_CURRENCY, "EUR");
+    g_assert_nonnull (fixture->currency);
+    fixture->local_account = create_account (fixture, "Checking",
+                                             ACCT_TYPE_BANK);
+    fixture->internal_account = create_account (fixture, "Savings",
+                                                 ACCT_TYPE_BANK);
+    fixture->linked_account = create_account (fixture, "House",
+                                              ACCT_TYPE_BANK);
+    fixture->category_account = create_account (fixture, "Rent",
+                                                 ACCT_TYPE_EXPENSE);
+
+    gnc_ab_set_account_bankcode (fixture->local_account, LOCAL_BANK_CODE);
+    gnc_ab_set_account_accountid (fixture->local_account,
+                                  LOCAL_ACCOUNT_NUMBER);
+    xaccAccountBeginEdit (fixture->internal_account);
+    xaccAccountSetCode (fixture->internal_account, INTERNAL_IBAN);
+    xaccAccountCommitEdit (fixture->internal_account);
+    gnc_ab_set_account_bankcode (fixture->linked_account, LINKED_BANK_CODE);
+    gnc_ab_set_account_accountid (fixture->linked_account,
+                                  LINKED_ACCOUNT_NUMBER);
+}
+
+static void
+teardown (StandingOrderFixture *fixture, gconstpointer user_data)
+{
+    qof_book_destroy (fixture->book);
+}
+
+static AB_IMEXPORTER_CONTEXT *
+snapshot_new (void)
+{
+    AB_IMEXPORTER_CONTEXT *context = AB_ImExporterContext_new ();
+    AB_IMEXPORTER_ACCOUNTINFO *account_info =
+        AB_ImExporterAccountInfo_new ();
+
+    AB_ImExporterAccountInfo_SetIban (account_info, LOCAL_IBAN);
+    AB_ImExporterAccountInfo_SetBankCode (account_info, LOCAL_BANK_CODE);
+    AB_ImExporterAccountInfo_SetAccountNumber (account_info,
+                                               LOCAL_ACCOUNT_NUMBER);
+    AB_ImExporterContext_AddAccountInfo (context, account_info);
+
+    return context;
+}
+
+static AB_TRANSACTION *
+snapshot_add_order (AB_IMEXPORTER_CONTEXT *context, const gchar *id,
+                    const gchar *remote_iban, const gchar *amount,
+                    const gchar *purpose)
+{
+    AB_IMEXPORTER_ACCOUNTINFO_LIST *account_infos =
+        AB_ImExporterContext_GetAccountInfoList (context);
+    AB_IMEXPORTER_ACCOUNTINFO *account_info =
+        AB_ImExporterAccountInfo_List_First (account_infos);
+    AB_TRANSACTION *transaction = AB_Transaction_new ();
+    AB_VALUE *value = AB_Value_fromString (amount);
+    GWEN_DATE *first_date =
+        GWEN_Date_fromStringWithTemplate ("20260818", "YYYYMMDD");
+    GWEN_DATE *next_date =
+        GWEN_Date_fromStringWithTemplate ("20260918", "YYYYMMDD");
+
+    AB_Transaction_SetType (transaction, AB_Transaction_TypeStandingOrder);
+    AB_Transaction_SetFiId (transaction, id);
+    AB_Transaction_SetLocalIban (transaction, LOCAL_IBAN);
+    AB_Transaction_SetLocalBankCode (transaction, LOCAL_BANK_CODE);
+    AB_Transaction_SetLocalAccountNumber (transaction,
+                                          LOCAL_ACCOUNT_NUMBER);
+    AB_Transaction_SetRemoteIban (transaction, remote_iban);
+    AB_Transaction_SetValue (transaction, value);
+    AB_Transaction_SetPurpose (transaction, purpose);
+    AB_Transaction_SetPeriod (transaction, AB_Transaction_PeriodMonthly);
+    AB_Transaction_SetCycle (transaction, 1);
+    AB_Transaction_SetExecutionDay (transaction, 18);
+    AB_Transaction_SetFirstDate (transaction, first_date);
+    AB_Transaction_SetNextDate (transaction, next_date);
+    AB_ImExporterAccountInfo_AddTransaction (account_info, transaction);
+
+    GWEN_Date_free (next_date);
+    GWEN_Date_free (first_date);
+    AB_Value_free (value);
+
+    return transaction;
+}
+
+static SchedXaction *
+find_imported_sx (StandingOrderFixture *fixture, const gchar *id)
+{
+    SchedXactions *sxes = gnc_book_get_schedxactions (fixture->book);
+    GList *node;
+
+    for (node = sxes->sx_list; node; node = node->next)
+    {
+        SchedXaction *sx = node->data;
+        gchar *stored_id = gnc_ab_get_standing_order_id (sx);
+        gboolean matches = g_strcmp0 (stored_id, id) == 0;
+
+        g_free (stored_id);
+        if (matches)
+            return sx;
+    }
+
+    return NULL;
+}
+
+static Account *
+template_split_account (StandingOrderFixture *fixture, Split *split)
+{
+    GncGUID *account_guid = NULL;
+    Account *account;
+
+    qof_instance_get (QOF_INSTANCE (split),
+                      "sx-account", &account_guid,
+                      NULL);
+    account = account_guid ? xaccAccountLookup (account_guid, fixture->book)
+                           : NULL;
+    guid_free (account_guid);
+    return account;
+}
+
+static Split *
+find_template_split (StandingOrderFixture *fixture, SchedXaction *sx,
+                     Account *account)
+{
+    GList *splits = xaccSchedXactionGetSplits (sx);
+    GList *node;
+    Split *matched_split = NULL;
+
+    for (node = splits; node; node = node->next)
+    {
+        Split *split = node->data;
+
+        if (template_split_account (fixture, split) == account)
+        {
+            matched_split = split;
+            break;
+        }
+    }
+    g_list_free (splits);
+
+    return matched_split;
+}
+
+static Split *
+find_counter_split (StandingOrderFixture *fixture, SchedXaction *sx)
+{
+    GList *splits = xaccSchedXactionGetSplits (sx);
+    GList *node;
+    Split *counter_split = NULL;
+
+    for (node = splits; node; node = node->next)
+    {
+        Split *split = node->data;
+
+        if (template_split_account (fixture, split) != fixture->local_account)
+        {
+            counter_split = split;
+            break;
+        }
+    }
+    g_list_free (splits);
+
+    return counter_split;
+}
+
+static gnc_numeric
+split_numeric (Split *split, const gchar *property)
+{
+    gnc_numeric result = gnc_numeric_zero ();
+    gnc_numeric *value = NULL;
+
+    qof_instance_get (QOF_INSTANCE (split), property, &value, NULL);
+    if (value)
+        result = *value;
+    g_free (value);
+
+    return result;
+}
+
+static void
+assert_template_amount (StandingOrderFixture *fixture, SchedXaction *sx,
+                        gnc_numeric expected)
+{
+    Split *local_split = find_template_split (fixture, sx,
+                                              fixture->local_account);
+    Split *counter_split = find_counter_split (fixture, sx);
+
+    g_assert_nonnull (local_split);
+    g_assert_nonnull (counter_split);
+    g_assert_true (gnc_numeric_equal (
+        split_numeric (local_split, "sx-credit-numeric"), expected));
+    g_assert_true (gnc_numeric_equal (
+        split_numeric (counter_split, "sx-debit-numeric"), expected));
+}
+
+static void
+sync_result_clear (GncABStandingOrderSyncResult *result)
+{
+    g_list_free (result->to_edit);
+    result->to_edit = NULL;
+}
+
+static void
+test_aqb_standing_order_external_account (StandingOrderFixture *fixture,
+                                          gconstpointer user_data)
+{
+    AB_IMEXPORTER_CONTEXT *snapshot = snapshot_new ();
+    GncABStandingOrderSyncResult result;
+    SchedXaction *sx;
+    Split *counter_split;
+    Account *counter_account;
+
+    snapshot_add_order (snapshot, "external-order", EXTERNAL_IBAN,
+                        "75.55", "Monthly rent");
+    result = gnc_ab_import_standing_orders (snapshot,
+                                            fixture->local_account);
+
+    g_assert_cmpuint (result.received, ==, 1);
+    g_assert_cmpuint (result.created, ==, 1);
+    g_assert_cmpuint (result.updated, ==, 0);
+    sx = find_imported_sx (fixture, "fiid:external-order");
+    g_assert_nonnull (sx);
+    g_assert_cmpint (xaccAccountGetSplitsSize (sx->template_acct), ==, 2);
+    assert_template_amount (fixture, sx, gnc_numeric_create (7555, 100));
+
+    counter_split = find_counter_split (fixture, sx);
+    counter_account = template_split_account (fixture, counter_split);
+    g_assert_nonnull (counter_account);
+    g_assert_true (g_str_has_prefix (xaccAccountGetName (counter_account),
+                                     "Imbalance-"));
+
+    sync_result_clear (&result);
+    AB_ImExporterContext_free (snapshot);
+}
+
+static void
+test_aqb_standing_order_account_code_is_not_iban (StandingOrderFixture *fixture,
+                                                  gconstpointer user_data)
+{
+    AB_IMEXPORTER_CONTEXT *snapshot = snapshot_new ();
+    GncABStandingOrderSyncResult result;
+    SchedXaction *sx;
+    Split *counter_split;
+
+    snapshot_add_order (snapshot, "internal-order", INTERNAL_IBAN,
+                        "150.00", "Savings");
+    result = gnc_ab_import_standing_orders (snapshot,
+                                            fixture->local_account);
+
+    g_assert_cmpuint (result.created, ==, 1);
+    sx = find_imported_sx (fixture, "fiid:internal-order");
+    g_assert_nonnull (sx);
+    counter_split = find_counter_split (fixture, sx);
+    g_assert_true (template_split_account (fixture, counter_split)
+                   != fixture->internal_account);
+    g_assert_true (g_str_has_prefix (xaccAccountGetName (
+        template_split_account (fixture, counter_split)), "Imbalance-"));
+
+    sync_result_clear (&result);
+    AB_ImExporterContext_free (snapshot);
+}
+
+static void
+test_aqb_standing_order_linked_account (StandingOrderFixture *fixture,
+                                        gconstpointer user_data)
+{
+    AB_IMEXPORTER_CONTEXT *snapshot = snapshot_new ();
+    AB_TRANSACTION *order;
+    GncABStandingOrderSyncResult result;
+    gnc_commodity *usd;
+    SchedXaction *sx;
+    Split *counter_split;
+
+    order = snapshot_add_order (snapshot, "linked-order", LINKED_IBAN,
+                                "165.00", "House account");
+    AB_Transaction_SetRemoteBankCode (order, LINKED_BANK_CODE);
+    AB_Transaction_SetRemoteAccountNumber (order, LINKED_ACCOUNT_NUMBER);
+
+    usd = gnc_commodity_table_lookup (
+        gnc_commodity_table_get_table (fixture->book),
+        GNC_COMMODITY_NS_CURRENCY, "USD");
+    g_assert_nonnull (usd);
+    xaccAccountBeginEdit (fixture->linked_account);
+    xaccAccountSetCommodity (fixture->linked_account, usd);
+    xaccAccountCommitEdit (fixture->linked_account);
+    result = gnc_ab_import_standing_orders (snapshot,
+                                            fixture->local_account);
+
+    g_assert_cmpuint (result.created, ==, 1);
+    sx = find_imported_sx (fixture, "fiid:linked-order");
+    g_assert_nonnull (sx);
+    counter_split = find_counter_split (fixture, sx);
+    g_assert_true (template_split_account (fixture, counter_split)
+                   == fixture->linked_account);
+
+    sync_result_clear (&result);
+    AB_ImExporterContext_free (snapshot);
+}
+
+static void
+test_aqb_standing_order_weekly_without_purpose (StandingOrderFixture *fixture,
+                                                gconstpointer user_data)
+{
+    AB_IMEXPORTER_CONTEXT *snapshot = snapshot_new ();
+    AB_TRANSACTION *order;
+    GncABStandingOrderSyncResult result;
+    SchedXaction *sx;
+    GList *schedule;
+    Split *counter_split;
+
+    order = snapshot_add_order (snapshot, "weekly-order", EXTERNAL_IBAN,
+                                "25.00", NULL);
+    AB_Transaction_SetPeriod (order, AB_Transaction_PeriodWeekly);
+    AB_Transaction_SetCycle (order, 2);
+    result = gnc_ab_import_standing_orders (snapshot,
+                                            fixture->local_account);
+
+    g_assert_cmpuint (result.created, ==, 1);
+    sx = find_imported_sx (fixture, "fiid:weekly-order");
+    g_assert_nonnull (sx);
+    schedule = gnc_sx_get_schedule (sx);
+    g_assert_cmpuint (g_list_length (schedule), ==, 1);
+    g_assert_cmpint (recurrenceGetPeriodType (schedule->data), ==,
+                     PERIOD_WEEK);
+    g_assert_cmpuint (recurrenceGetMultiplier (schedule->data), ==, 2);
+    counter_split = find_counter_split (fixture, sx);
+    g_assert_cmpstr (xaccTransGetDescription (
+        xaccSplitGetParent (counter_split)), ==, "");
+
+    sync_result_clear (&result);
+    AB_ImExporterContext_free (snapshot);
+}
+
+static void
+test_aqb_standing_order_past_start_uses_next_occurrence (StandingOrderFixture *fixture,
+                                                         gconstpointer user_data)
+{
+    AB_IMEXPORTER_CONTEXT *snapshot = snapshot_new ();
+    AB_TRANSACTION *order;
+    GWEN_DATE *past_date;
+    GncABStandingOrderSyncResult result;
+    SchedXaction *sx;
+    const GDate *start_date;
+    GDate today;
+
+    order = snapshot_add_order (snapshot, "past-start", EXTERNAL_IBAN,
+                                "30.00", "Existing order");
+    past_date = GWEN_Date_fromStringWithTemplate ("20000118", "YYYYMMDD");
+    AB_Transaction_SetFirstDate (order, past_date);
+    AB_Transaction_SetNextDate (order, past_date);
+    GWEN_Date_free (past_date);
+
+    result = gnc_ab_import_standing_orders (snapshot,
+                                            fixture->local_account);
+
+    g_assert_cmpuint (result.created, ==, 1);
+    sx = find_imported_sx (fixture, "fiid:past-start");
+    g_assert_nonnull (sx);
+    start_date = xaccSchedXactionGetStartDate (sx);
+    gnc_gdate_set_today (&today);
+    g_assert_true (g_date_valid (start_date));
+    g_assert_cmpint (g_date_compare (start_date, &today), >=, 0);
+    g_assert_cmpuint (g_date_get_day (start_date), ==, 18);
+
+    sync_result_clear (&result);
+    AB_ImExporterContext_free (snapshot);
+}
+
+static void
+test_aqb_standing_order_update_preserves_local_edits (StandingOrderFixture *fixture,
+                                                      gconstpointer user_data)
+{
+    AB_IMEXPORTER_CONTEXT *snapshot = snapshot_new ();
+    AB_TRANSACTION *order;
+    GncABStandingOrderSyncResult result;
+    SchedXaction *sx;
+    Split *counter_split;
+    Split *local_split;
+    Split *tagging_split;
+    Transaction *template_trans;
+    GList *schedule;
+    gboolean autocreate;
+    gboolean notify;
+    gnc_numeric zero = gnc_numeric_zero ();
+    const GncGUID *category_guid =
+        xaccAccountGetGUID (fixture->category_account);
+    const GncGUID *tagging_guid =
+        xaccAccountGetGUID (fixture->internal_account);
+
+    snapshot_add_order (snapshot, "updated-order", EXTERNAL_IBAN,
+                        "75.00", "Initial purpose");
+    result = gnc_ab_import_standing_orders (snapshot,
+                                            fixture->local_account);
+    g_assert_cmpuint (result.created, ==, 1);
+    sx = find_imported_sx (fixture, "fiid:updated-order");
+    g_assert_cmpuint (g_list_length (result.to_edit), ==, 1);
+    g_assert_true (result.to_edit->data == sx);
+    sync_result_clear (&result);
+    counter_split = find_counter_split (fixture, sx);
+    template_trans = xaccSplitGetParent (counter_split);
+    xaccSchedXactionSetName (sx, "Local standing order");
+    xaccTransBeginEdit (template_trans);
+    xaccTransSetDescription (template_trans, "Local description");
+    xaccTransSetNotes (template_trans, "Local notes");
+    xaccSplitSetMemo (counter_split, "Local memo");
+    xaccSplitSetAction (counter_split, "Local action");
+    qof_instance_set (QOF_INSTANCE (counter_split),
+                      "sx-account", category_guid,
+                      NULL);
+    tagging_split = xaccMallocSplit (fixture->book);
+    xaccSplitSetMemo (tagging_split, "Local tag");
+    xaccSplitSetAction (tagging_split, "Tag");
+    xaccAccountInsertSplit (sx->template_acct, tagging_split);
+    qof_instance_set (QOF_INSTANCE (tagging_split),
+                      "sx-credit-formula", "",
+                      "sx-credit-numeric", &zero,
+                      "sx-debit-formula", "",
+                      "sx-debit-numeric", &zero,
+                      "sx-account", tagging_guid,
+                      NULL);
+    xaccTransAppendSplit (template_trans, tagging_split);
+    xaccTransCommitEdit (template_trans);
+    xaccSchedXactionSetAutoCreate (sx, TRUE, TRUE);
+    xaccSchedXactionSetAdvanceCreation (sx, 93);
+    xaccSchedXactionSetAdvanceReminder (sx, 14);
+    AB_ImExporterContext_free (snapshot);
+
+    snapshot = snapshot_new ();
+    order = snapshot_add_order (snapshot, "updated-order", EXTERNAL_IBAN,
+                                "80.00", "Updated purpose");
+    AB_Transaction_SetPeriod (order, AB_Transaction_PeriodWeekly);
+    AB_Transaction_SetCycle (order, 2);
+    result = gnc_ab_import_standing_orders (snapshot,
+                                            fixture->local_account);
+
+    g_assert_cmpuint (result.created, ==, 0);
+    g_assert_cmpuint (result.updated, ==, 1);
+    g_assert_cmpuint (g_list_length (
+        gnc_book_get_schedxactions (fixture->book)->sx_list), ==, 1);
+    xaccSchedXactionGetAutoCreate (sx, &autocreate, &notify);
+    g_assert_true (autocreate);
+    g_assert_true (notify);
+    g_assert_cmpint (xaccSchedXactionGetAdvanceCreation (sx), ==, 93);
+    g_assert_cmpint (xaccSchedXactionGetAdvanceReminder (sx), ==, 14);
+    schedule = gnc_sx_get_schedule (sx);
+    g_assert_cmpint (recurrenceGetPeriodType (schedule->data), ==,
+                     PERIOD_MONTH);
+    g_assert_cmpuint (recurrenceGetMultiplier (schedule->data), ==, 1);
+    g_assert_cmpstr (xaccSchedXactionGetName (sx), ==,
+                     "Local standing order");
+    counter_split = find_counter_split (fixture, sx);
+    g_assert_true (template_split_account (fixture, counter_split)
+                   == fixture->category_account);
+    g_assert_cmpstr (xaccTransGetDescription (xaccSplitGetParent (counter_split)),
+                     ==, "Local description");
+    g_assert_cmpstr (xaccTransGetNotes (xaccSplitGetParent (counter_split)),
+                     ==, "Local notes");
+    g_assert_cmpstr (xaccSplitGetMemo (counter_split), ==, "Local memo");
+    g_assert_cmpstr (xaccSplitGetAction (counter_split), ==, "Local action");
+    local_split = find_template_split (fixture, sx, fixture->local_account);
+    g_assert_true (gnc_numeric_equal (
+        split_numeric (local_split, "sx-credit-numeric"),
+        gnc_numeric_create (80, 1)));
+    g_assert_true (gnc_numeric_equal (
+        split_numeric (counter_split, "sx-debit-numeric"),
+        gnc_numeric_create (75, 1)));
+    g_assert_cmpint (xaccAccountGetSplitsSize (sx->template_acct), ==, 3);
+    g_assert_true (template_split_account (fixture, tagging_split)
+                   == fixture->internal_account);
+    g_assert_cmpstr (xaccSplitGetMemo (tagging_split), ==, "Local tag");
+    g_assert_cmpstr (xaccSplitGetAction (tagging_split), ==, "Tag");
+    g_assert_true (gnc_numeric_zero_p (
+        split_numeric (tagging_split, "sx-credit-numeric")));
+    g_assert_true (gnc_numeric_zero_p (
+        split_numeric (tagging_split, "sx-debit-numeric")));
+    g_assert_cmpuint (g_list_length (result.to_edit), ==, 1);
+    g_assert_true (result.to_edit->data == sx);
+
+    sync_result_clear (&result);
+    AB_ImExporterContext_free (snapshot);
+}
+
+static void
+test_aqb_standing_order_missing_is_disabled (StandingOrderFixture *fixture,
+                                             gconstpointer user_data)
+{
+    AB_IMEXPORTER_CONTEXT *snapshot = snapshot_new ();
+    GncABStandingOrderSyncResult result;
+    SchedXaction *sx;
+
+    snapshot_add_order (snapshot, "removed-order", EXTERNAL_IBAN,
+                        "40.00", "Service provider");
+    result = gnc_ab_import_standing_orders (snapshot,
+                                            fixture->local_account);
+    g_assert_cmpuint (result.created, ==, 1);
+    sx = find_imported_sx (fixture, "fiid:removed-order");
+    g_assert_true (xaccSchedXactionGetEnabled (sx));
+    sync_result_clear (&result);
+    AB_ImExporterContext_free (snapshot);
+
+    snapshot = snapshot_new ();
+    result = gnc_ab_import_standing_orders (snapshot,
+                                            fixture->local_account);
+
+    g_assert_cmpuint (result.disabled, ==, 1);
+    g_assert_false (xaccSchedXactionGetEnabled (sx));
+
+    sync_result_clear (&result);
+    AB_ImExporterContext_free (snapshot);
+    snapshot = snapshot_new ();
+    snapshot_add_order (snapshot, "removed-order", EXTERNAL_IBAN,
+                        "40.00", "Service provider");
+    result = gnc_ab_import_standing_orders (snapshot,
+                                            fixture->local_account);
+
+    g_assert_cmpuint (result.updated, ==, 1);
+    g_assert_true (xaccSchedXactionGetEnabled (sx));
+
+    sync_result_clear (&result);
+    AB_ImExporterContext_free (snapshot);
+}
+
+static void
+test_aqb_standing_order_invalid_update_stays_enabled (StandingOrderFixture *fixture,
+                                                      gconstpointer user_data)
+{
+    AB_IMEXPORTER_CONTEXT *snapshot = snapshot_new ();
+    GncABStandingOrderSyncResult result;
+    SchedXaction *sx;
+
+    snapshot_add_order (snapshot, "temporarily-invalid", EXTERNAL_IBAN,
+                        "40.00", "Service provider");
+    result = gnc_ab_import_standing_orders (snapshot,
+                                            fixture->local_account);
+    g_assert_cmpuint (result.created, ==, 1);
+    sx = find_imported_sx (fixture, "fiid:temporarily-invalid");
+    g_assert_nonnull (sx);
+    sync_result_clear (&result);
+    AB_ImExporterContext_free (snapshot);
+
+    snapshot = snapshot_new ();
+    snapshot_add_order (snapshot, "temporarily-invalid", EXTERNAL_IBAN,
+                        "0.00", "Service provider");
+    g_test_expect_message ("gnc.import.aqbanking", G_LOG_LEVEL_WARNING,
+                           "*without an amount*");
+    result = gnc_ab_import_standing_orders (snapshot,
+                                            fixture->local_account);
+    g_test_assert_expected_messages ();
+
+    g_assert_cmpuint (result.updated, ==, 0);
+    g_assert_cmpuint (result.skipped, ==, 1);
+    g_assert_cmpuint (result.disabled, ==, 0);
+    g_assert_true (xaccSchedXactionGetEnabled (sx));
+    assert_template_amount (fixture, sx, gnc_numeric_create (40, 1));
+
+    sync_result_clear (&result);
+    AB_ImExporterContext_free (snapshot);
+}
+
+static void
+test_aqb_standing_order_does_not_claim_manual_sx (StandingOrderFixture *fixture,
+                                                  gconstpointer user_data)
+{
+    AB_IMEXPORTER_CONTEXT *snapshot = snapshot_new ();
+    SchedXaction *manual_sx = xaccSchedXactionMalloc (fixture->book);
+    GncABStandingOrderSyncResult result;
+
+    xaccSchedXactionSetName (manual_sx, "Online Banking: Same name");
+    gnc_sxes_add_sx (gnc_book_get_schedxactions (fixture->book), manual_sx);
+    snapshot_add_order (snapshot, "separate-order", EXTERNAL_IBAN,
+                        "20.00", "Same name");
+    result = gnc_ab_import_standing_orders (snapshot,
+                                            fixture->local_account);
+
+    g_assert_cmpuint (result.created, ==, 1);
+    g_assert_cmpuint (g_list_length (
+        gnc_book_get_schedxactions (fixture->book)->sx_list), ==, 2);
+
+    sync_result_clear (&result);
+    AB_ImExporterContext_free (snapshot);
+}
+
+static void
+test_aqb_standing_order_fallback_identity_distinguishes_amounts (StandingOrderFixture *fixture,
+                                                                 gconstpointer user_data)
+{
+    AB_IMEXPORTER_CONTEXT *snapshot = snapshot_new ();
+    GncABStandingOrderSyncResult result;
+
+    snapshot_add_order (snapshot, NULL, EXTERNAL_IBAN,
+                        "10.00", "Same destination");
+    snapshot_add_order (snapshot, NULL, EXTERNAL_IBAN,
+                        "20.00", "Same destination");
+    result = gnc_ab_import_standing_orders (snapshot,
+                                            fixture->local_account);
+
+    g_assert_cmpuint (result.received, ==, 2);
+    g_assert_cmpuint (result.created, ==, 2);
+    g_assert_cmpuint (g_list_length (
+        gnc_book_get_schedxactions (fixture->book)->sx_list), ==, 2);
+
+    sync_result_clear (&result);
+    AB_ImExporterContext_free (snapshot);
+}
+
+static void
+test_aqb_standing_order_invalid_period_is_skipped (StandingOrderFixture *fixture,
+                                                   gconstpointer user_data)
+{
+    AB_IMEXPORTER_CONTEXT *snapshot = snapshot_new ();
+    AB_TRANSACTION *order;
+    GncABStandingOrderSyncResult result;
+
+    order = snapshot_add_order (snapshot, "invalid-period", EXTERNAL_IBAN,
+                                "20.00", "Unsupported schedule");
+    AB_Transaction_SetPeriod (order, AB_Transaction_PeriodUnknown);
+    g_test_expect_message ("gnc.import.aqbanking", G_LOG_LEVEL_WARNING,
+                           "*unsupported period*");
+    result = gnc_ab_import_standing_orders (snapshot,
+                                            fixture->local_account);
+    g_test_assert_expected_messages ();
+
+    g_assert_cmpuint (result.created, ==, 0);
+    g_assert_cmpuint (result.received, ==, 1);
+    g_assert_cmpuint (result.skipped, ==, 1);
+    g_assert_null (gnc_book_get_schedxactions (fixture->book)->sx_list);
+
+    sync_result_clear (&result);
+    AB_ImExporterContext_free (snapshot);
+}
+
+static void
+test_aqb_standing_order_invalid_start_date_is_skipped (StandingOrderFixture *fixture,
+                                                       gconstpointer user_data)
+{
+    AB_IMEXPORTER_CONTEXT *snapshot = snapshot_new ();
+    AB_TRANSACTION *order;
+    GncABStandingOrderSyncResult result;
+
+    order = snapshot_add_order (snapshot, "invalid-start", EXTERNAL_IBAN,
+                                "20.00", "Missing start date");
+    AB_Transaction_SetFirstDate (order, NULL);
+    AB_Transaction_SetNextDate (order, NULL);
+    g_test_expect_message ("gnc.import.aqbanking", G_LOG_LEVEL_WARNING,
+                           "*valid start date*");
+    result = gnc_ab_import_standing_orders (snapshot,
+                                            fixture->local_account);
+    g_test_assert_expected_messages ();
+
+    g_assert_cmpuint (result.created, ==, 0);
+    g_assert_cmpuint (result.received, ==, 1);
+    g_assert_cmpuint (result.skipped, ==, 1);
+    g_assert_null (gnc_book_get_schedxactions (fixture->book)->sx_list);
+
+    sync_result_clear (&result);
+    AB_ImExporterContext_free (snapshot);
+}
+
+static void
+test_aqb_standing_order_zero_amount_is_skipped (StandingOrderFixture *fixture,
+                                                gconstpointer user_data)
+{
+    AB_IMEXPORTER_CONTEXT *snapshot = snapshot_new ();
+    GncABStandingOrderSyncResult result;
+
+    snapshot_add_order (snapshot, "zero-amount", EXTERNAL_IBAN,
+                        "0.00", "Invalid amount");
+    g_test_expect_message ("gnc.import.aqbanking", G_LOG_LEVEL_WARNING,
+                           "*without an amount*");
+    result = gnc_ab_import_standing_orders (snapshot,
+                                            fixture->local_account);
+    g_test_assert_expected_messages ();
+
+    g_assert_cmpuint (result.received, ==, 1);
+    g_assert_cmpuint (result.created, ==, 0);
+    g_assert_cmpuint (result.skipped, ==, 1);
+    g_assert_null (gnc_book_get_schedxactions (fixture->book)->sx_list);
+
+    sync_result_clear (&result);
+    AB_ImExporterContext_free (snapshot);
+}
+
+static void
+test_aqb_standing_order_missing_source_details_uses_default (StandingOrderFixture *fixture,
+                                                             gconstpointer user_data)
+{
+    AB_IMEXPORTER_CONTEXT *snapshot = snapshot_new ();
+    AB_IMEXPORTER_ACCOUNTINFO_LIST *account_infos =
+        AB_ImExporterContext_GetAccountInfoList (snapshot);
+    AB_IMEXPORTER_ACCOUNTINFO *account_info =
+        AB_ImExporterAccountInfo_List_First (account_infos);
+    GncABStandingOrderSyncResult result;
+
+    AB_ImExporterAccountInfo_SetIban (account_info, NULL);
+    AB_ImExporterAccountInfo_SetBankCode (account_info, NULL);
+    AB_ImExporterAccountInfo_SetAccountNumber (account_info, NULL);
+    snapshot_add_order (snapshot, "missing-source-details", EXTERNAL_IBAN,
+                        "20.00", "No account details");
+    result = gnc_ab_import_standing_orders (snapshot,
+                                            fixture->local_account);
+
+    g_assert_cmpuint (result.received, ==, 1);
+    g_assert_cmpuint (result.created, ==, 1);
+    g_assert_nonnull (find_imported_sx (fixture,
+                                       "fiid:missing-source-details"));
+
+    sync_result_clear (&result);
+    AB_ImExporterContext_free (snapshot);
+}
+
+static void
+test_aqb_standing_order_unknown_source_is_skipped (StandingOrderFixture *fixture,
+                                                   gconstpointer user_data)
+{
+    AB_IMEXPORTER_CONTEXT *snapshot = snapshot_new ();
+    AB_IMEXPORTER_ACCOUNTINFO_LIST *account_infos =
+        AB_ImExporterContext_GetAccountInfoList (snapshot);
+    AB_IMEXPORTER_ACCOUNTINFO *account_info =
+        AB_ImExporterAccountInfo_List_First (account_infos);
+    GncABStandingOrderSyncResult result;
+
+    AB_ImExporterAccountInfo_SetIban (account_info, EXTERNAL_IBAN);
+    AB_ImExporterAccountInfo_SetBankCode (account_info, "99999999");
+    AB_ImExporterAccountInfo_SetAccountNumber (account_info, "1234567890");
+    snapshot_add_order (snapshot, "unknown-source", EXTERNAL_IBAN,
+                        "20.00", "Wrong source");
+    g_test_expect_message ("gnc.import.aqbanking", G_LOG_LEVEL_WARNING,
+                           "*unknown source account*");
+    result = gnc_ab_import_standing_orders (snapshot,
+                                            fixture->local_account);
+    g_test_assert_expected_messages ();
+
+    g_assert_cmpuint (result.received, ==, 1);
+    g_assert_cmpuint (result.created, ==, 0);
+    g_assert_cmpuint (result.skipped, ==, 1);
+    g_assert_null (gnc_book_get_schedxactions (fixture->book)->sx_list);
+
+    sync_result_clear (&result);
+    AB_ImExporterContext_free (snapshot);
+}
+
+void
+test_suite_aqb_standing_orders (void)
+{
+    GNC_TEST_ADD (suitename, "external-account", StandingOrderFixture, NULL,
+                  setup, test_aqb_standing_order_external_account, teardown);
+    GNC_TEST_ADD (suitename, "account-code-is-not-iban", StandingOrderFixture, NULL,
+                  setup, test_aqb_standing_order_account_code_is_not_iban, teardown);
+    GNC_TEST_ADD (suitename, "linked-account", StandingOrderFixture, NULL,
+                  setup, test_aqb_standing_order_linked_account, teardown);
+    GNC_TEST_ADD (suitename, "weekly-without-purpose", StandingOrderFixture, NULL,
+                  setup, test_aqb_standing_order_weekly_without_purpose, teardown);
+    GNC_TEST_ADD (suitename, "past-start-uses-next-occurrence", StandingOrderFixture, NULL,
+                  setup, test_aqb_standing_order_past_start_uses_next_occurrence, teardown);
+    GNC_TEST_ADD (suitename, "update-preserves-local-edits", StandingOrderFixture, NULL,
+                  setup, test_aqb_standing_order_update_preserves_local_edits, teardown);
+    GNC_TEST_ADD (suitename, "missing-is-disabled", StandingOrderFixture, NULL,
+                  setup, test_aqb_standing_order_missing_is_disabled, teardown);
+    GNC_TEST_ADD (suitename, "invalid-update-stays-enabled", StandingOrderFixture, NULL,
+                  setup, test_aqb_standing_order_invalid_update_stays_enabled, teardown);
+    GNC_TEST_ADD (suitename, "manual-sx-not-claimed", StandingOrderFixture, NULL,
+                  setup, test_aqb_standing_order_does_not_claim_manual_sx, teardown);
+    GNC_TEST_ADD (suitename, "fallback-identity-distinguishes-amounts", StandingOrderFixture, NULL,
+                  setup, test_aqb_standing_order_fallback_identity_distinguishes_amounts, teardown);
+    GNC_TEST_ADD (suitename, "invalid-period-is-skipped", StandingOrderFixture, NULL,
+                  setup, test_aqb_standing_order_invalid_period_is_skipped, teardown);
+    GNC_TEST_ADD (suitename, "invalid-start-date-is-skipped", StandingOrderFixture, NULL,
+                  setup, test_aqb_standing_order_invalid_start_date_is_skipped, teardown);
+    GNC_TEST_ADD (suitename, "zero-amount-is-skipped", StandingOrderFixture, NULL,
+                  setup, test_aqb_standing_order_zero_amount_is_skipped, teardown);
+    GNC_TEST_ADD (suitename, "missing-source-details-uses-default", StandingOrderFixture, NULL,
+                  setup, test_aqb_standing_order_missing_source_details_uses_default, teardown);
+    GNC_TEST_ADD (suitename, "unknown-source-is-skipped", StandingOrderFixture, NULL,
+                  setup, test_aqb_standing_order_unknown_source_is_skipped, teardown);
+}
diff --git a/po/POTFILES.in b/po/POTFILES.in
index a0e5d7f20f..1c6e108f9e 100644
--- a/po/POTFILES.in
+++ b/po/POTFILES.in
@@ -309,6 +309,7 @@ gnucash/import-export/aqb/dialog-ab-trans.c
 gnucash/import-export/aqb/gnc-ab-getbalance.c
 gnucash/import-export/aqb/gnc-ab-gettrans.c
 gnucash/import-export/aqb/gnc-ab-kvp.c
+gnucash/import-export/aqb/gnc-ab-standing-orders.c
 gnucash/import-export/aqb/gnc-ab-transfer.c
 gnucash/import-export/aqb/gnc-ab-utils.c
 gnucash/import-export/aqb/gnc-file-aqb-import.c



Summary of changes:
 gnucash/import-export/aqb/CMakeLists.txt           |   2 +
 gnucash/import-export/aqb/gnc-ab-gettrans.c        | 113 ++-
 gnucash/import-export/aqb/gnc-ab-gettrans.h        |  12 +-
 gnucash/import-export/aqb/gnc-ab-kvp.c             |  63 ++
 gnucash/import-export/aqb/gnc-ab-kvp.h             |  16 +
 gnucash/import-export/aqb/gnc-ab-standing-orders.c | 846 ++++++++++++++++++++
 gnucash/import-export/aqb/gnc-ab-standing-orders.h |  43 ++
 gnucash/import-export/aqb/gnc-plugin-aqbanking.c   |  26 +
 gnucash/import-export/aqb/gnc-plugin-aqbanking.ui  |   5 +
 gnucash/import-export/aqb/test/CMakeLists.txt      |   3 +-
 gnucash/import-export/aqb/test/test-aqb.c          |   2 +
 .../import-export/aqb/test/test-standing-orders.c  | 850 +++++++++++++++++++++
 po/POTFILES.in                                     |   1 +
 13 files changed, 1979 insertions(+), 3 deletions(-)
 create mode 100644 gnucash/import-export/aqb/gnc-ab-standing-orders.c
 create mode 100644 gnucash/import-export/aqb/gnc-ab-standing-orders.h
 create mode 100644 gnucash/import-export/aqb/test/test-standing-orders.c



More information about the gnucash-changes mailing list