From d463faf134b4b85f0e31300c1446e27ddc90817c Mon Sep 17 00:00:00 2001 From: Ruben Carlo Benante Date: Tue, 7 Jul 2026 18:05:08 -0300 Subject: [PATCH] FinanceBuddy: carry money across recurrence/duplicate, advance installments (v1.0.1) --- Model/FinancePersistence.php | 62 ++++++++++++++++++++++++++++++++++++ Plugin.php | 7 +++- VERSION | 2 +- 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/Model/FinancePersistence.php b/Model/FinancePersistence.php index d30d7f4..4530b12 100644 --- a/Model/FinancePersistence.php +++ b/Model/FinancePersistence.php @@ -48,6 +48,28 @@ class FinancePersistence extends Base if ($this->pending !== null) { $this->persist((int) $task_id, $this->pending['project_id'], $this->pending['fields']); $this->pending = null; + return; + } + + // Not a form submission. Native recurrence builds the next occurrence with a plain task + // create (which copies no metadata) after setting recurrence_parent to the source task, so + // carry the finance metadata across -- otherwise the recurring card loses its money. + $task = $this->taskFinderModel->getById((int) $task_id); + + if (! empty($task['recurrence_parent'])) { + // A recurrence is the NEXT occurrence, so advance the installment (p2/30 -> p3/30). + $this->copyMetadata((int) $task['recurrence_parent'], (int) $task_id, true); + } + } + + /** + * model:task:duplication:aftersave -- the manual "Duplicate" action. TaskDuplicationModel does + * not copy metadata, so carry the finance fields to the new task. + */ + public function onDuplication(array &$hook_values) + { + if (! empty($hook_values['source_task_id']) && ! empty($hook_values['destination_task_id'])) { + $this->copyMetadata((int) $hook_values['source_task_id'], (int) $hook_values['destination_task_id']); } } @@ -114,6 +136,46 @@ class FinancePersistence extends Base )); } + /** + * Copy the finance metadata from one task to another (recurrence next occurrence, or a manual + * duplicate). When $advance is true, bump the current installment by one -- clamped at the + * total so a finite plan stops at its last installment (e.g. p30/30 stays p30/30). + */ + private function copyMetadata($source_id, $dest_id, $advance = false) + { + if ($source_id <= 0 || $dest_id <= 0) { + return; + } + + $amount = $this->taskMetadataModel->get($source_id, FinanceHelper::AMOUNT_KEY, ''); + + // Nothing to carry over if the source has no money set. + if ($amount === '' || (float) $amount <= 0) { + return; + } + + $direction = $this->taskMetadataModel->get($source_id, FinanceHelper::DIRECTION_KEY, 'debit'); + $current = $this->taskMetadataModel->get($source_id, FinanceHelper::INSTALLMENT_CURRENT_KEY, ''); + $total = $this->taskMetadataModel->get($source_id, FinanceHelper::INSTALLMENT_TOTAL_KEY, ''); + + if ($advance && $current !== '' && ctype_digit((string) $current)) { + $next = (int) $current + 1; + + if ($total !== '' && ctype_digit((string) $total) && $next > (int) $total) { + $next = (int) $total; + } + + $current = (string) $next; + } + + $this->taskMetadataModel->save($dest_id, array( + FinanceHelper::AMOUNT_KEY => $amount, + FinanceHelper::DIRECTION_KEY => $direction === 'credit' ? 'credit' : 'debit', + FinanceHelper::INSTALLMENT_CURRENT_KEY => $current, + FinanceHelper::INSTALLMENT_TOTAL_KEY => $total, + )); + } + private function intOrBlank($raw) { $raw = trim((string) $raw); diff --git a/Plugin.php b/Plugin.php index 3eeea92..6c49cdd 100644 --- a/Plugin.php +++ b/Plugin.php @@ -29,6 +29,11 @@ class Plugin extends Base $this->hook->on('model:task:creation:aftersave', array($persistence, 'onCreationAfterSave')); $this->hook->on('model:task:modification:prepare', array($persistence, 'onModificationPrepare')); + // Carry the finance metadata onto copies (which Kanboard does not copy): native recurrence + // advances the installment for the next occurrence (creation:aftersave via recurrence_parent), + // and a manual Duplicate copies it verbatim. + $this->hook->on('model:task:duplication:aftersave', array($persistence, 'onDuplication')); + // The line-3 money badge on each board card, rendered directly under the title. Two hooks: // "private" is the normal board, "public" is the read-only board shared by token. $this->template->hook->attach('template:board:private:task:after-title', 'financeBuddy:board/task_money'); @@ -67,7 +72,7 @@ class Plugin extends Base public function getPluginVersion() { - return '1.0.0'; + return '1.0.1'; } public function getPluginHomepage() diff --git a/VERSION b/VERSION index 82f581d..7dbdd0b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -FinanceBuddy v1.0 +FinanceBuddy v1.0.1