19 Commits
v0.2 ... master

Author SHA1 Message Date
d77fc01195 FinanceBuddy v1.3.0 — per-board currency shipped. All clean (php -l, ASCII, README + TODO synced) 2026-07-13 09:45:22 -03:00
3caf592756 README ASCII 2026-07-13 08:56:17 -03:00
0a464db214 README: More-plugins section -> Dr. Beco heading + full sibling list (v1.2.1) 2026-07-13 08:47:38 -03:00
2de0dc19a0 per-column Hide/Show total toggle in column menu (on/off flag, relocate JS) (v1.2.0) 2026-07-12 08:10:23 -03:00
405c7d2431 Hide total in columns 2026-07-12 08:05:14 -03:00
b4b7ef74d6 FinanceBuddy: separate the Integrations section with a trailing rule (v1.1.2) 2026-07-11 15:42:12 -03:00
eac8617723 clamp installment at total+1 as RecoReco plan-complete tombstone (v1.1.1) 2026-07-10 09:13:36 -03:00
3e0cf10d6c right-justify the column total in a rounded pill 2026-07-08 09:30:50 -03:00
524f293e37 put column total on its own line via flex-wrap (v1.0.5) 2026-07-08 09:25:08 -03:00
5ba4029912 column total on its own full-width line; Total: black (v1.0.4) 2026-07-08 09:19:07 -03:00
305ee59477 advance template installment on RecoReco spawn (v1.0.3) 2026-07-08 00:32:48 -03:00
7fde8cb3d9 open-ended installments when only the current is set (v1.0.2) 2026-07-07 18:16:51 -03:00
d463faf134 FinanceBuddy: carry money across recurrence/duplicate, advance installments (v1.0.1) 2026-07-07 18:05:08 -03:00
974a3ae8bd FinanceBuddy: show the money badge on the opened card; v1.0.0 2026-07-07 17:47:50 -03:00
0833c4a1c0 FinanceBuddy: keep signed amounts on one line, relabel column total (v0.4.1) 2026-07-07 17:11:08 -03:00
d2383a76b7 FinanceBuddy: per-column net total in the header t1 (v0.4) 2026-07-07 17:05:16 -03:00
6328f3632f FinanceBuddy: per-column net total in the header (v0.4) 2026-07-07 17:05:03 -03:00
6c23c4aeb2 FinanceBuddy: card money badge on line 3 (v0.3.1) 2026-07-07 16:59:11 -03:00
2c9ef4ce7d FinanceBuddy: finance fields in task form + metadata persistence (v0.3) 2026-07-07 16:50:40 -03:00
13 changed files with 851 additions and 25 deletions

View File

@@ -1,6 +1,99 @@
/*
* FinanceBuddy styles.
*
* Empty for now. The money badge (.fb-badge / .fb-installments) and the per-column total
* (.fb-column-total) styling are added as those features land in later versions.
*/
/* The column header (.board-column-expanded-header) is a flex row, so the total is a flex item. To
drop it onto its own line under the title, let that header wrap (scoped with :has to headers that
actually contain the total -- i.e. enabled boards only) and give the total full width. No
background (dropped per request); "Total:" stays black, only the amount span is colored by sign. */
.board-column-expanded-header:has(.fb-column-total) {
flex-wrap: wrap;
}
.fb-column-total {
flex-basis: 100%;
margin-top: 2px;
text-align: right;
}
/* The visible pill hugs the text (not the full width) and is right-justified on its line. */
.fb-total-pill {
display: inline-block;
padding: 0 5px;
font-weight: bold;
font-size: 0.95em;
color: #000;
background: rgba(0, 0, 0, 0.05);
border-radius: 3px;
}
/* Keep the signed amount together ("-R$450,00"); a break before it (after "Total:") is fine. */
.fb-amount {
white-space: nowrap;
}
.fb-total-debit {
color: #b94a48;
}
.fb-total-credit {
color: #468847;
}
.fb-total-zero {
color: #777;
}
/* Line-3 money badge on board cards. Its own background guarantees contrast on any card color. */
.fb-line {
margin: 2px 0 4px;
line-height: 1.4;
}
.fb-badge {
display: inline-block;
padding: 1px 6px;
border-radius: 3px;
color: #fff;
font-weight: bold;
font-size: 1.1em;
white-space: nowrap;
}
.fb-badge-debit {
background: #b94a48;
}
.fb-badge-credit {
background: #468847;
}
.fb-installments {
margin-left: 6px;
color: #000;
font-size: 1.05em;
}
/* Task-form finance fieldset (middle column). */
.fb-form-directions {
margin-bottom: 8px;
}
.fb-form-inline {
display: inline-block;
margin-right: 14px;
cursor: pointer;
}
.fb-form-installments {
display: flex;
align-items: center;
}
.fb-form-installments .fb-form-num {
width: 5em;
}
.fb-form-installments span {
margin: 0 8px;
}

59
Asset/js/relocate.js Normal file
View File

@@ -0,0 +1,59 @@
/*
* FinanceBuddy -- place the "Hide/Show column total" entry inside the native column menu.
*
* Kanboard only exposes the template:board:column:dropdown hook AFTER the column menu <ul>, and its
* dropdown JS clones just that <ul> when the menu opens. So the hook item cannot reach the menu on
* its own. column_dropdown.php renders the item hidden next to the menu; this script moves each such
* item into that column's real <ul> so it appears alongside "Hide this column", etc.
*
* BoardDragAndDrop.refresh() does $("#board-container").replaceWith(data) on every drag-and-drop and
* poll refresh, so the observer is bound to the STABLE parent of #board-container (which survives the
* swap), not to #board-container itself -- otherwise it would be left watching a detached node and
* the entry would vanish until a full page reload.
*/
(function () {
"use strict";
function relocate() {
var items = document.querySelectorAll(".financebuddy-menu-item");
for (var i = 0; i < items.length; i++) {
var li = items[i];
// Already moved into a menu list: just make sure it is visible.
if (li.closest("ul")) {
li.style.display = "";
continue;
}
// The hook renders the item as a child of the column's dropdown wrapper; its first <ul>
// is the native menu.
var menu = li.parentNode ? li.parentNode.querySelector("ul") : null;
if (menu) {
menu.appendChild(li);
li.style.display = "";
}
}
}
function init() {
var container = document.getElementById("board-container");
if (!container) {
return;
}
relocate();
if (window.MutationObserver && container.parentNode) {
new MutationObserver(relocate).observe(container.parentNode, { childList: true, subtree: true });
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

View File

@@ -0,0 +1,34 @@
<?php
namespace Kanboard\Plugin\FinanceBuddy\Controller;
use Kanboard\Controller\BaseController;
use Kanboard\Core\Controller\AccessForbiddenException;
use Kanboard\Plugin\FinanceBuddy\Helper\FinanceHelper;
/**
* Toggle a board column's header total on/off from the column dropdown menu. The per-column flag
* lives in project metadata; project-manager gated (same scope as enabling FinanceBuddy). Returns
* to the board, whose reload re-renders the header with or without the total.
*/
class FinanceColumnController extends BaseController
{
public function toggleTotal()
{
$this->checkCSRFParam();
$project = $this->getProject();
if (! $this->helper->user->hasProjectAccess('ProjectEditController', 'edit', $project['id'])) {
throw new AccessForbiddenException();
}
$key = FinanceHelper::HIDE_TOTAL_PREFIX.$this->request->getIntegerParam('column_id');
// Store the literal 'on'/'off' (both non-empty) rather than 1/0, so the flag is never lost
// to PHP's empty("0") === true on the metadata save.
$hidden = $this->projectMetadataModel->get($project['id'], $key, FinanceHelper::HIDE_TOTAL_OFF) === FinanceHelper::HIDE_TOTAL_ON;
$this->projectMetadataModel->save($project['id'], array($key => $hidden ? FinanceHelper::HIDE_TOTAL_OFF : FinanceHelper::HIDE_TOTAL_ON));
$this->response->redirect($this->helper->url->to('BoardViewController', 'show', array('project_id' => $project['id'])));
}
}

View File

@@ -8,12 +8,39 @@ use Kanboard\Core\Base;
* Template/controller helper for FinanceBuddy.
*
* FinanceBuddy is opt-in per board: every render and save point is gated on isEnabled(), so an
* un-enabled board is untouched. The flag lives in project metadata (key financebuddy_enabled),
* written by the checkbox on the project's Integrations page.
* un-enabled board is untouched. The enable flag lives in project metadata (financebuddy_enabled);
* the per-task money lives in task metadata under the keys below.
*/
class FinanceHelper extends Base
{
const ENABLED_KEY = 'financebuddy_enabled';
const AMOUNT_KEY = 'financebuddy_amount';
const DIRECTION_KEY = 'financebuddy_direction';
const INSTALLMENT_CURRENT_KEY = 'financebuddy_installment_current';
const INSTALLMENT_TOTAL_KEY = 'financebuddy_installment_total';
// Per-board currency symbol shown before every amount (default "R$"). Up to 4 chars; the stored
// value is trimmed, capped, and defaulted on read, so an empty or oversized value is harmless.
const CURRENCY_KEY = 'financebuddy_currency';
const CURRENCY_DEFAULT = 'R$';
const CURRENCY_MAX_LENGTH = 4;
// Per-column opt-out of the header total. Stored per column in PROJECT metadata as
// financebuddy_hide_total_<column_id> = 'on' (hidden) or 'off' (shown, the default). Literal
// on/off, never 1/0, so the flag is never dropped by the metadata save (PHP empty("0") === true).
const HIDE_TOTAL_PREFIX = 'financebuddy_hide_total_';
const HIDE_TOTAL_ON = 'on';
const HIDE_TOTAL_OFF = 'off';
// Hidden marker present only when the task form was submitted with the finance fieldset, so
// non-finance task updates (moves, API calls) are ignored by the persistence hooks.
const FORM_MARKER = 'financebuddy_form';
// Memoized per project_id: the board hooks below call isEnabled() once per card.
private static $enabledCache = array();
// Memoized per project_id: currency() is likewise resolved once per card/column render.
private static $currencyCache = array();
/**
* Is FinanceBuddy enabled for this board?
@@ -23,6 +50,169 @@ class FinanceHelper extends Base
*/
public function isEnabled($project_id)
{
return (int) $this->projectMetadataModel->get($project_id, self::ENABLED_KEY, 0) === 1;
$project_id = (int) $project_id;
if (! isset(self::$enabledCache[$project_id])) {
self::$enabledCache[$project_id] = (int) $this->projectMetadataModel->get($project_id, self::ENABLED_KEY, 0) === 1;
}
return self::$enabledCache[$project_id];
}
/**
* Per-board currency symbol shown before amounts: the stored value trimmed and capped to
* CURRENCY_MAX_LENGTH, or CURRENCY_DEFAULT ("R$") when empty. Memoized per project.
*
* @param integer $project_id
* @return string
*/
public function currency($project_id)
{
$project_id = (int) $project_id;
if (! isset(self::$currencyCache[$project_id])) {
$symbol = trim((string) $this->projectMetadataModel->get($project_id, self::CURRENCY_KEY, ''));
$symbol = mb_substr($symbol, 0, self::CURRENCY_MAX_LENGTH);
self::$currencyCache[$project_id] = $symbol === '' ? self::CURRENCY_DEFAULT : $symbol;
}
return self::$currencyCache[$project_id];
}
/**
* Stored finance values for a task, with defaults. One query (getAll) since the board renders
* this per card.
*
* @param integer $task_id
* @return array
*/
public function get($task_id)
{
$all = $this->taskMetadataModel->getAll($task_id);
return array(
'amount' => isset($all[self::AMOUNT_KEY]) ? $all[self::AMOUNT_KEY] : '',
'direction' => (isset($all[self::DIRECTION_KEY]) && $all[self::DIRECTION_KEY] === 'credit') ? 'credit' : 'debit',
'installment_current' => isset($all[self::INSTALLMENT_CURRENT_KEY]) ? $all[self::INSTALLMENT_CURRENT_KEY] : '',
'installment_total' => isset($all[self::INSTALLMENT_TOTAL_KEY]) ? $all[self::INSTALLMENT_TOTAL_KEY] : '',
);
}
/**
* Net money for a board column: credits positive, debits negative, summed over the cards in
* the column. One grouped query over the column's task ids (the task rows carry no metadata).
*
* @param array $column A board column as passed to template:board:column:header ('tasks').
* @return float
*/
public function columnNet(array $column)
{
if (empty($column['tasks'])) {
return 0.0;
}
$task_ids = array();
foreach ($column['tasks'] as $task) {
$task_ids[] = (int) $task['id'];
}
$rows = $this->db->table('task_has_metadata')
->columns('task_id', 'name', 'value')
->in('task_id', $task_ids)
->in('name', array(self::AMOUNT_KEY, self::DIRECTION_KEY))
->findAll();
$amounts = array();
$directions = array();
foreach ($rows as $row) {
if ($row['name'] === self::AMOUNT_KEY) {
$amounts[$row['task_id']] = (float) $row['value'];
} else {
$directions[$row['task_id']] = $row['value'];
}
}
$net = 0.0;
foreach ($amounts as $task_id => $amount) {
if ($amount <= 0) {
continue;
}
$is_credit = isset($directions[$task_id]) && $directions[$task_id] === 'credit';
$net += $is_credit ? $amount : -$amount;
}
return $net;
}
/**
* Has this column's header total been hidden (per-column opt-out)?
*
* @param integer $project_id
* @param integer $column_id
* @return bool
*/
public function isTotalHidden($project_id, $column_id)
{
return $this->projectMetadataModel->get((int) $project_id, self::HIDE_TOTAL_PREFIX.(int) $column_id, self::HIDE_TOTAL_OFF) === self::HIDE_TOTAL_ON;
}
/**
* Values to prefill the task-form fields: a re-post (after a validation error) wins, then the
* stored metadata for an existing task, otherwise blanks for a new task.
*
* @param array $values
* @return array
*/
public function formValues(array $values)
{
if (isset($values[self::FORM_MARKER])) {
return array(
'amount' => isset($values[self::AMOUNT_KEY]) ? $values[self::AMOUNT_KEY] : '',
'direction' => (isset($values[self::DIRECTION_KEY]) && $values[self::DIRECTION_KEY] === 'credit') ? 'credit' : 'debit',
'installment_current' => isset($values[self::INSTALLMENT_CURRENT_KEY]) ? $values[self::INSTALLMENT_CURRENT_KEY] : '',
'installment_total' => isset($values[self::INSTALLMENT_TOTAL_KEY]) ? $values[self::INSTALLMENT_TOTAL_KEY] : '',
);
}
if (! empty($values['id'])) {
return $this->get($values['id']);
}
return array('amount' => '', 'direction' => 'debit', 'installment_current' => '', 'installment_total' => '');
}
/**
* Parse a user-typed amount. Accepts a dot or a comma as the decimal separator and an optional
* "R$"; there is no thousand separator. Returns a non-negative float (0.0 when unparseable).
*
* @param string $raw
* @return float
*/
public static function parseAmount($raw)
{
$raw = str_replace(array('R$', ' '), '', trim((string) $raw));
$raw = str_replace(',', '.', $raw);
if ($raw === '' || ! is_numeric($raw)) {
return 0.0;
}
$value = round((float) $raw, 2);
return $value > 0 ? $value : 0.0;
}
/**
* Format a money value for display: two decimals, comma decimal separator, no thousand
* separator (e.g. 20.9 -> "20,90"). Accepts a float or a canonical dot-string.
*
* @param float|string $amount
* @return string
*/
public static function money($amount)
{
return number_format((float) $amount, 2, ',', '');
}
}

View File

@@ -0,0 +1,226 @@
<?php
namespace Kanboard\Plugin\FinanceBuddy\Model;
use Kanboard\Core\Base;
use Kanboard\Plugin\FinanceBuddy\Helper\FinanceHelper;
/**
* Persists the FinanceBuddy fields that live in the core task form.
*
* PicoDb's insert()/update() build SQL straight from the value keys, so any non-column field left
* in $values would break task saving (this is why core unset()s "tags"). We therefore:
* (a) strip every financebuddy_* key from $values in the create/modify "prepare" hooks, and
* (b) write the values to task metadata separately.
*
* Everything here runs synchronously inside the model call during the HTTP request (the "prepare"
* and "aftersave" reference hooks), so it does NOT depend on the optionally-async event queue. The
* finance values are read straight from $values (getValues() cannot be reused: the controller
* already consumed the CSRF token). The task id comes from the aftersave hook (create) or the
* route parameter (modify).
*/
class FinancePersistence extends Base
{
private $pending = null;
/**
* model:task:creation:prepare -- fires before INSERT, $values by reference, no task id yet.
*/
public function onCreationPrepare(array &$values)
{
$this->pending = null;
if (isset($values[FinanceHelper::FORM_MARKER])) {
$this->pending = array(
'project_id' => isset($values['project_id']) ? (int) $values['project_id'] : 0,
'fields' => $this->extract($values),
);
}
$this->strip($values);
}
/**
* model:task:creation:aftersave -- fires after INSERT, hands over the new task id by reference.
*/
public function onCreationAfterSave(&$task_id)
{
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']);
}
}
/**
* recoreco:task:spawned -- RecoReco (calendar recurrence) just spawned a copy. Its ongoing card
* is the TEMPLATE that stays (unlike native recurrence, whose ongoing card is the new child), so
* advance the SOURCE template's installment. The clone already copied the current installment
* verbatim via onDuplication during RecoReco's duplicate() call.
*/
public function onRecoRecoSpawn(array &$payload)
{
if (! empty($payload['source_task_id'])) {
$this->advanceInstallment((int) $payload['source_task_id']);
}
}
/**
* model:task:modification:prepare -- fires before UPDATE, $values by reference. The task id is
* removed from $values by core, but it is the route parameter of the modification request.
*/
public function onModificationPrepare(array &$values)
{
if (isset($values[FinanceHelper::FORM_MARKER])) {
$task_id = $this->request->getIntegerParam('task_id');
$project_id = isset($values['project_id']) ? (int) $values['project_id'] : 0;
if ($task_id > 0) {
$this->persist($task_id, $project_id, $this->extract($values));
}
}
$this->strip($values);
}
private function extract(array $values)
{
return array(
'amount' => isset($values[FinanceHelper::AMOUNT_KEY]) ? $values[FinanceHelper::AMOUNT_KEY] : '',
'direction' => (isset($values[FinanceHelper::DIRECTION_KEY]) && $values[FinanceHelper::DIRECTION_KEY] === 'credit') ? 'credit' : 'debit',
'installment_current' => isset($values[FinanceHelper::INSTALLMENT_CURRENT_KEY]) ? $values[FinanceHelper::INSTALLMENT_CURRENT_KEY] : '',
'installment_total' => isset($values[FinanceHelper::INSTALLMENT_TOTAL_KEY]) ? $values[FinanceHelper::INSTALLMENT_TOTAL_KEY] : '',
);
}
private function strip(array &$values)
{
foreach (array_keys($values) as $key) {
if (strpos($key, 'financebuddy_') === 0) {
unset($values[$key]);
}
}
}
private function persist($task_id, $project_id, array $fields)
{
// Only touch metadata on boards where a manager opted in.
if ((int) $this->projectMetadataModel->get($project_id, FinanceHelper::ENABLED_KEY, 0) !== 1) {
return;
}
$amount = FinanceHelper::parseAmount($fields['amount']);
// No (or cleared) amount: drop any finance metadata so the card goes back to plain.
if ($amount <= 0) {
foreach (array(FinanceHelper::AMOUNT_KEY, FinanceHelper::DIRECTION_KEY, FinanceHelper::INSTALLMENT_CURRENT_KEY, FinanceHelper::INSTALLMENT_TOTAL_KEY) as $key) {
$this->taskMetadataModel->remove($task_id, $key);
}
return;
}
$this->taskMetadataModel->save($task_id, array(
FinanceHelper::AMOUNT_KEY => number_format($amount, 2, '.', ''),
FinanceHelper::DIRECTION_KEY => $fields['direction'] === 'credit' ? 'credit' : 'debit',
FinanceHelper::INSTALLMENT_CURRENT_KEY => $this->intOrBlank($fields['installment_current']),
FinanceHelper::INSTALLMENT_TOTAL_KEY => $this->intOrBlank($fields['installment_total']),
));
}
/**
* 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,
));
}
/**
* Advance a task's current installment by one, in place. A finite plan clamps at total+1, one
* past the last installment: that overflow is the "plan complete" tombstone (p4/3) that RecoReco
* reads to stop a following recurrence, and it caps runaway when nothing follows. Clones still
* read p1/total .. pTotal/total because a clone snapshots current BEFORE this advance. An
* open-ended plan (no total) increments unbounded (p2 -> p3); cards with no numeric current
* installment are left alone.
*/
private function advanceInstallment($task_id)
{
$current = $this->taskMetadataModel->get($task_id, FinanceHelper::INSTALLMENT_CURRENT_KEY, '');
if ($current === '' || ! ctype_digit((string) $current)) {
return;
}
$total = $this->taskMetadataModel->get($task_id, FinanceHelper::INSTALLMENT_TOTAL_KEY, '');
$next = (int) $current + 1;
if ($total !== '' && ctype_digit((string) $total) && $next > (int) $total + 1) {
$next = (int) $total + 1;
}
$this->taskMetadataModel->save($task_id, array(
FinanceHelper::INSTALLMENT_CURRENT_KEY => (string) $next,
));
}
private function intOrBlank($raw)
{
$raw = trim((string) $raw);
return ($raw !== '' && ctype_digit($raw) && (int) $raw > 0) ? (string) (int) $raw : '';
}
}

View File

@@ -17,6 +17,48 @@ class Plugin extends Base
// Per-board opt-in: a checkbox on the project's Settings -> Integrations page. Nothing
// else in the plugin renders or saves until a project manager enables it for that board.
$this->template->hook->attach('template:project:integrations', 'financeBuddy:project/integration');
// Finance fields in the task create/edit form (middle column). The template gates itself
// on the board being enabled. These are not task columns, so core cannot persist them:
// FinancePersistence strips them from the task write and stores them in task metadata,
// all synchronously via the model hooks below.
$this->template->hook->attach('template:task:form:second-column', 'financeBuddy:task/form_fields');
$persistence = new \Kanboard\Plugin\FinanceBuddy\Model\FinancePersistence($this->container);
$this->hook->on('model:task:creation:prepare', array($persistence, 'onCreationPrepare'));
$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'));
// RecoReco (calendar recurrence) spawns a copy and emits this signal. The clone already got
// the finance verbatim (via onDuplication above); here we advance the SOURCE template's
// installment, since RecoReco's ongoing card is the template that stays.
$this->hook->on('recoreco:task:spawned', array($persistence, 'onRecoRecoSpawn'));
// 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');
$this->template->hook->attach('template:board:public:task:after-title', 'financeBuddy:board/task_money');
// Per-column net total (credits - debits) in the column header.
$this->template->hook->attach('template:board:column:header', 'financeBuddy:board/column_total');
// "Hide/Show column total" toggle in the column dropdown menu. The only column-menu hook
// renders after the native <ul>, so column_dropdown.php emits the item hidden and relocate.js
// moves it into the menu (same technique as BulkMoveTasks, with the stable-parent observer).
$this->template->hook->attach('template:board:column:dropdown', 'financeBuddy:board/column_dropdown');
$this->hook->on('template:layout:js', array(
'template' => 'plugins/FinanceBuddy/Asset/js/relocate.js',
));
// The same money badge at the top of the opened card (task view), under the title, so a
// card shows its money whether you are on the board or reading the card. Reuses the board
// badge template (both hooks pass $task).
$this->template->hook->attach('template:task:details:top', 'financeBuddy:board/task_money');
}
public function getHelpers()
@@ -43,7 +85,7 @@ class Plugin extends Base
public function getPluginVersion()
{
return '0.2.0';
return '1.3.0';
}
public function getPluginHomepage()

101
README.md
View File

@@ -1,8 +1,8 @@
# FinanceBuddy -- money on your Kanboard cards
Attach a money value to a board card, see it on the card, and total it per column -- so a
Kanboard project can double as a simple, visual finance board (bills, installments, income
vs expenses).
Attach a money value to each board card, see it on the card, and total it per column, so a
Kanboard project can double as a simple, visual finance board -- bills, installments, income
vs expenses.
FinanceBuddy is **opt-in per board**: installing it changes nothing until a project manager
enables it for a specific board under Settings -> Integrations. Boards that are not enabled
@@ -10,30 +10,76 @@ look exactly as before.
## The idea: a bill-lifecycle board
Kanboard imposes no structure, so FinanceBuddy is built to fit one rather than dictate it. The
suggested organization:
Kanboard imposes no structure, so FinanceBuddy fits one rather than dictating it. A layout that
works well:
- **Columns = a bill's status:** `Templates -> Due this month -> Paid`. Cards flow left to
right as they are paid.
- **Columns = a bill's status:** `Templates -> Due this month -> Paid`. Cards flow left to right
as they are paid.
- **Tags = category:** housing, utilities, comms, food...
- **Due date = the bill's due date;** color/priority = urgency.
- Each card carries a **money value** (debit or credit) and, optionally, an **installment**
count (`p2/10` = 2nd of 10 payments; leave the total blank for an open-ended recurring bill).
- Each card carries a **money value** and, optionally, an **installment** count.
Recurring monthly bills can live as template cards spawned each month by the companion RecoReco
plugin; FinanceBuddy only owns the money.
## What it does
Once a board is enabled:
### Set the money on a card
The task edit/create form gains a **Finance** section in the middle column:
- **Amount** in the board's currency (default R$).
- **Debit** or **Credit** (money out vs money in).
- **Installment** current / total (for example `2` of `10`). Leave the total blank for an
open-ended recurring bill.
### See it on the card
A badge appears just under the card title -- both on the board and at the top of the opened
card, so the money is visible whether you glance at the board or read the card (no need to open
the edit form):
- **red** with a `-` for a debit, **green** with a `+` for a credit (both the color and the sign
carry the meaning, and the badge's own background stays readable on any card color);
- followed by `pN/M` when a total is set (for example `p2/10`), hidden for open-ended bills.
### Total per column
Each column header shows a **`Total:`** of its cards -- credits positive, debits negative (the
net): red when negative (what you owe), green when positive, gray at zero. It is shown for every
column, including `Total: R$0,00`.
You can hide the total on a per-column basis: open the column's dropdown menu (the caret next to
the column title) and pick **"Hide column total"**; the same menu then offers **"Show column
total"** to bring it back. This is a per-board manager action, remembered per column.
### Installments and recurrence
FinanceBuddy carries the money across copies. Kanboard's built-in recurrence and RecoReco's
calendar recurrence advance the installment on the next occurrence (`p2/10 -> p3/10`); a manual
duplicate copies it as-is. When a RecoReco-followed plan finishes, the parked template is left one
past the last installment (`p3/3 -> p4/3`) as a "plan complete" marker that RecoReco reads to stop
recurring; the copies themselves still read `p1/3 .. p3/3`. Open-ended plans (blank total) keep
counting.
## Money format
Amounts are shown in R$ with a comma decimal and no thousand separator (for example
`R$1234,56`). Input accepts either a dot or a comma as the decimal separator, so `1234.56` and
`1234,56` are the same value.
Amounts show with a comma decimal and no thousand separator (for example `R$1234,56`). Input
accepts either a dot or a comma as the decimal separator, so `1234.56` and `1234,56` are the same
value.
## Status
The currency symbol shown before every amount is **per board**: set it under
Settings -> Integrations next to the enable checkbox (up to 4 characters, e.g. `US$`, `EUR`, `GBP`).
Leave it empty for the default `R$`.
Early development. This version registers the plugin and loads its stylesheet; the per-board
toggle, the in-form editing, the card badge, and the per-column totals arrive in the following
versions.
## How it stores data
No database migration. The per-board enable flag is stored in project metadata; each card's
money is stored in task metadata. The finance fields live in the normal task form but are saved
separately (they are not task columns), so ordinary task saving is untouched. Uninstalling the
plugin leaves that metadata harmlessly in place and changes nothing else.
## Requirements
@@ -43,8 +89,29 @@ versions.
Copy this folder into your Kanboard installation as `plugins/FinanceBuddy/`. The directory name
must be exactly `FinanceBuddy` (Kanboard derives the plugin namespace from the folder name). No
build step and no database migration.
build step and no database migration. Then, on each board you want to track, go to
Settings -> Integrations and tick "Enable FinanceBuddy on this board".
## License
AGPL-3.0. See LICENSE.
## More Kanboard plugins by Dr. Beco
All free and AGPL-3.0, at [code.beco.cc](https://code.beco.cc/beco):
- **[RecoReco](https://code.beco.cc/beco/RecoReco)** -- calendar-scheduled recurring cards (yearly,
monthly, weekly, daily), driven by the card due date.
- **[QualKard](https://code.beco.cc/beco/QualKard)** -- flashcard spaced repetition: grade a card by
dragging it to a column, and it comes back when due (needs RecoReco).
- **[WorkspaceOrg](https://code.beco.cc/beco/WorkspaceOrg)** -- group your projects into personal
per-user workspaces, shown on a "My workspaces" dashboard page with a badge on each project.
- **[OrganonTweaks](https://code.beco.cc/beco/OrganonTweaks)** -- an umbrella of small
quality-of-life board tweaks: remove an empty column, always show the comment icon, emphasize due
dates, extra search keywords and shared board filters, and more.
- **[BulkMoveTasks](https://code.beco.cc/beco/BulkMoveTasks)** -- move every task from one board
column to another in a single action.
- **[ShrinkVertically](https://code.beco.cc/beco/ShrinkVertically)** -- shrink vertically-collapsed
board columns so the horizontal scrollbar stays within reach.
- **[TweakDrag](https://code.beco.cc/beco/TweakDrag)** -- board drag and touch niceties:
drag-to-scroll, a wider column gap, and smoother card dragging.

View File

@@ -0,0 +1,17 @@
<?php if ($this->FinanceHelper->isEnabled($column['project_id']) && $this->user->hasProjectAccess('ProjectEditController', 'edit', $column['project_id'])): ?>
<?php /* Rendered here (outside the menu <ul>) by the only column-menu hook, then moved into the
column's real dropdown <ul> by Asset/js/relocate.js so it sits next to the native
entries. Hidden until relocated to avoid a flash in the header. */ ?>
<?php $hidden = $this->FinanceHelper->isTotalHidden($column['project_id'], $column['id']); ?>
<li class="financebuddy-menu-item" style="display: none;">
<?= $this->url->link(
$hidden
? '<i class="fa fa-eye fa-fw"></i> '.t('Show column total')
: '<i class="fa fa-eye-slash fa-fw"></i> '.t('Hide column total'),
'FinanceColumnController',
'toggleTotal',
array('plugin' => 'FinanceBuddy', 'project_id' => $column['project_id'], 'column_id' => $column['id']),
true
) ?>
</li>
<?php endif ?>

View File

@@ -0,0 +1,30 @@
<?php
// Per-column net total in the column header (credits - debits). Shown in every column of an
// enabled board, including "Total: R$0,00" -- never hidden on zero. Server-side, re-renders on
// every board refresh. Rendered on its own full-width line under the title; "Total:" stays the
// default (black) and only the amount is colored by sign.
if (empty($column['project_id']) || ! $this->FinanceHelper->isEnabled($column['project_id'])) {
return;
}
// Per-column opt-out, toggled from the column dropdown menu.
if ($this->FinanceHelper->isTotalHidden($column['project_id'], $column['id'])) {
return;
}
$net = $this->FinanceHelper->columnNet($column);
if ($net > 0) {
$amount_class = 'fb-total-credit';
$sign = '+';
} elseif ($net < 0) {
$amount_class = 'fb-total-debit';
$sign = '-';
} else {
$amount_class = 'fb-total-zero';
$sign = '';
}
?>
<div class="fb-column-total">
<span class="fb-total-pill"><?= t('Total') ?>: <span class="fb-amount <?= $amount_class ?>"><?= $sign ?><?= $this->text->e($this->FinanceHelper->currency($column['project_id'])) ?><?= \Kanboard\Plugin\FinanceBuddy\Helper\FinanceHelper::money(abs($net)) ?></span></span>
</div>

View File

@@ -0,0 +1,32 @@
<?php
// Line-3 money badge, rendered under the card title via the after-title hooks. Server-side, so
// it re-renders correctly on every board refresh. Nothing shows unless the board is enabled and
// the card has a positive amount.
if (empty($task['project_id']) || ! $this->FinanceHelper->isEnabled($task['project_id'])) {
return;
}
$fb = $this->FinanceHelper->get($task['id']);
if ($fb['amount'] === '' || (float) $fb['amount'] <= 0) {
return;
}
$credit = $fb['direction'] === 'credit';
$sign = $credit ? '+' : '-';
$total = $fb['installment_total'];
$current = $fb['installment_current'];
// Installments: "p2/10" when a total is set; "p2" (open-ended) when only the current is set;
// nothing when neither is set. Default the current to 1 if only a total was entered.
$show_installments = $current !== '' || $total !== '';
$current_display = $current !== '' ? $current : '1';
?>
<div class="fb-line">
<span class="fb-badge <?= $credit ? 'fb-badge-credit' : 'fb-badge-debit' ?>">
<?= $sign ?><?= $this->text->e($this->FinanceHelper->currency($task['project_id'])) ?><?= \Kanboard\Plugin\FinanceBuddy\Helper\FinanceHelper::money($fb['amount']) ?>
</span>
<?php if ($show_installments): ?>
<span class="fb-installments">p<?= $this->text->e($current_display) ?><?= $total !== '' ? '/'.$this->text->e($total) : '' ?></span>
<?php endif ?>
</div>

View File

@@ -13,8 +13,15 @@
<p class="form-help">
<?= t('When enabled, cards on this board can carry a money value (debit or credit, with optional installments), shown on the card and totalled per column. Boards left disabled are unaffected.') ?>
</p>
<?= $this->form->label(t('Currency symbol'), 'financebuddy_currency') ?>
<?= $this->form->text('financebuddy_currency', $values, array(), array('maxlength="4"', 'placeholder="R$"')) ?>
<p class="form-help">
<?= t('Shown before every amount on this board (up to 4 characters). Leave empty for the default R$.') ?>
</p>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-blue"><?= t('Save') ?></button>
</div>
<hr>

View File

@@ -0,0 +1,29 @@
<?php if (empty($values['project_id']) || ! $this->FinanceHelper->isEnabled($values['project_id'])): ?>
<?php else: ?>
<?php $fb = $this->FinanceHelper->formValues($values) ?>
<div class="fb-form">
<input type="hidden" name="<?= \Kanboard\Plugin\FinanceBuddy\Helper\FinanceHelper::FORM_MARKER ?>" value="1">
<label for="financebuddy_amount"><?= $this->text->e(t('Amount (%s)', $this->FinanceHelper->currency($values['project_id']))) ?></label>
<input type="text" id="financebuddy_amount" name="financebuddy_amount" value="<?= $this->text->e($fb['amount']) ?>" placeholder="0,00" autocomplete="off">
<label><?= t('Type') ?></label>
<div class="fb-form-directions">
<label class="fb-form-inline">
<input type="radio" name="financebuddy_direction" value="debit" <?= $fb['direction'] !== 'credit' ? 'checked="checked"' : '' ?>> <?= t('Debit') ?>
</label>
<label class="fb-form-inline">
<input type="radio" name="financebuddy_direction" value="credit" <?= $fb['direction'] === 'credit' ? 'checked="checked"' : '' ?>> <?= t('Credit') ?>
</label>
</div>
<label for="financebuddy_installment_current"><?= t('Installment') ?></label>
<div class="fb-form-installments">
<input type="number" min="1" id="financebuddy_installment_current" name="financebuddy_installment_current" value="<?= $this->text->e($fb['installment_current']) ?>" class="fb-form-num">
<span><?= t('of') ?></span>
<input type="number" min="1" name="financebuddy_installment_total" value="<?= $this->text->e($fb['installment_total']) ?>" class="fb-form-num">
</div>
<p class="form-help"><?= t('Set a total for a finite plan (shows p2/10); leave it blank for an open-ended count (shows p2).') ?></p>
</div>
<?php endif ?>

View File

@@ -1 +1 @@
FinanceBuddy v0.2
FinanceBuddy v1.3.0