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, ¬ify);
+ 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