8 Commits

9 changed files with 282 additions and 25 deletions

View File

@@ -1,9 +1,39 @@
/* /*
* FinanceBuddy styles. * FinanceBuddy styles.
*
* The per-column total (.fb-column-total) styling is added in a later version.
*/ */
/* Per-column net total: its own full-width line under the column title (clears the right-floated
task count), so a wide amount never overflows the column. Keep the background pill (theme-safe
contrast). "Total:" is black; only the amount span is colored by sign. */
.fb-column-total {
display: block;
clear: both;
margin-top: 3px;
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. */ /* Line-3 money badge on board cards. Its own background guarantees contrast on any card color. */
.fb-line { .fb-line {
margin: 2px 0 4px; margin: 2px 0 4px;
@@ -17,6 +47,7 @@
color: #fff; color: #fff;
font-weight: bold; font-weight: bold;
font-size: 1.1em; font-size: 1.1em;
white-space: nowrap;
} }
.fb-badge-debit { .fb-badge-debit {

View File

@@ -62,6 +62,54 @@ class FinanceHelper extends Base
); );
} }
/**
* 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;
}
/** /**
* Values to prefill the task-form fields: a re-post (after a validation error) wins, then the * 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. * stored metadata for an existing task, otherwise blanks for a new task.

View File

@@ -48,6 +48,41 @@ class FinancePersistence extends Base
if ($this->pending !== null) { if ($this->pending !== null) {
$this->persist((int) $task_id, $this->pending['project_id'], $this->pending['fields']); $this->persist((int) $task_id, $this->pending['project_id'], $this->pending['fields']);
$this->pending = null; $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']);
} }
} }
@@ -114,6 +149,71 @@ 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,
));
}
/**
* Advance a task's current installment by one, in place. A finite plan clamps at the total
* (p10/10 stays p10/10); an open-ended one (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) {
$next = (int) $total;
}
$this->taskMetadataModel->save($task_id, array(
FinanceHelper::INSTALLMENT_CURRENT_KEY => (string) $next,
));
}
private function intOrBlank($raw) private function intOrBlank($raw)
{ {
$raw = trim((string) $raw); $raw = trim((string) $raw);

View File

@@ -29,10 +29,28 @@ class Plugin extends Base
$this->hook->on('model:task:creation:aftersave', array($persistence, 'onCreationAfterSave')); $this->hook->on('model:task:creation:aftersave', array($persistence, 'onCreationAfterSave'));
$this->hook->on('model:task:modification:prepare', array($persistence, 'onModificationPrepare')); $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: // 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. // "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:private:task:after-title', 'financeBuddy:board/task_money');
$this->template->hook->attach('template:board:public: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');
// 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() public function getHelpers()
@@ -59,7 +77,7 @@ class Plugin extends Base
public function getPluginVersion() public function getPluginVersion()
{ {
return '0.3.1'; return '1.0.4';
} }
public function getPluginHomepage() public function getPluginHomepage()

View File

@@ -1,8 +1,8 @@
# FinanceBuddy -- money on your Kanboard cards # 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 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 Kanboard project can double as a simple, visual finance board -- bills, installments, income
vs expenses). vs expenses.
FinanceBuddy is **opt-in per board**: installing it changes nothing until a project manager 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 enables it for a specific board under Settings -> Integrations. Boards that are not enabled
@@ -10,30 +10,59 @@ look exactly as before.
## The idea: a bill-lifecycle board ## The idea: a bill-lifecycle board
Kanboard imposes no structure, so FinanceBuddy is built to fit one rather than dictate it. The Kanboard imposes no structure, so FinanceBuddy fits one rather than dictating it. A layout that
suggested organization: works well:
- **Columns = a bill's status:** `Templates -> Due this month -> Paid`. Cards flow left to - **Columns = a bill's status:** `Templates -> Due this month -> Paid`. Cards flow left to right
right as they are paid. as they are paid.
- **Tags = category:** housing, utilities, comms, food... - **Tags = category:** housing, utilities, comms, food...
- **Due date = the bill's due date;** color/priority = urgency. - **Due date = the bill's due date;** color/priority = urgency.
- Each card carries a **money value** (debit or credit) and, optionally, an **installment** - Each card carries a **money value** and, optionally, an **installment** count.
count (`p2/10` = 2nd of 10 payments; leave the total blank for an open-ended recurring bill).
Recurring monthly bills can live as template cards spawned each month by the companion RecoReco Recurring monthly bills can live as template cards spawned each month by the companion RecoReco
plugin; FinanceBuddy only owns the money. 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 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`.
## Money format ## Money format
Amounts are shown in R$ with a comma decimal and no thousand separator (for example Amounts show in R$ with a comma decimal and no thousand separator (for example `R$1234,56`).
`R$1234,56`). Input accepts either a dot or a comma as the decimal separator, so `1234.56` and Input accepts either a dot or a comma as the decimal separator, so `1234.56` and `1234,56` are
`1234,56` are the same value. the same value.
## Status ## How it stores data
Early development. This version registers the plugin and loads its stylesheet; the per-board No database migration. The per-board enable flag is stored in project metadata; each card's
toggle, the in-form editing, the card badge, and the per-column totals arrive in the following money is stored in task metadata. The finance fields live in the normal task form but are saved
versions. 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 ## Requirements
@@ -43,7 +72,8 @@ versions.
Copy this folder into your Kanboard installation as `plugins/FinanceBuddy/`. The directory name 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 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 ## License

View File

@@ -0,0 +1,25 @@
<?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;
}
$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">
<?= t('Total') ?>: <span class="fb-amount <?= $amount_class ?>"><?= $sign ?>R$<?= \Kanboard\Plugin\FinanceBuddy\Helper\FinanceHelper::money(abs($net)) ?></span>
</div>

View File

@@ -15,13 +15,18 @@ if ($fb['amount'] === '' || (float) $fb['amount'] <= 0) {
$credit = $fb['direction'] === 'credit'; $credit = $fb['direction'] === 'credit';
$sign = $credit ? '+' : '-'; $sign = $credit ? '+' : '-';
$total = $fb['installment_total']; $total = $fb['installment_total'];
$current = $fb['installment_current'] !== '' ? $fb['installment_current'] : '1'; $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"> <div class="fb-line">
<span class="fb-badge <?= $credit ? 'fb-badge-credit' : 'fb-badge-debit' ?>"> <span class="fb-badge <?= $credit ? 'fb-badge-credit' : 'fb-badge-debit' ?>">
<?= $sign ?>R$<?= \Kanboard\Plugin\FinanceBuddy\Helper\FinanceHelper::money($fb['amount']) ?> <?= $sign ?>R$<?= \Kanboard\Plugin\FinanceBuddy\Helper\FinanceHelper::money($fb['amount']) ?>
</span> </span>
<?php if ($total !== ''): ?> <?php if ($show_installments): ?>
<span class="fb-installments">p<?= $this->text->e($current) ?>/<?= $this->text->e($total) ?></span> <span class="fb-installments">p<?= $this->text->e($current_display) ?><?= $total !== '' ? '/'.$this->text->e($total) : '' ?></span>
<?php endif ?> <?php endif ?>
</div> </div>

View File

@@ -24,6 +24,6 @@
<span><?= t('of') ?></span> <span><?= t('of') ?></span>
<input type="number" min="1" name="financebuddy_installment_total" value="<?= $this->text->e($fb['installment_total']) ?>" class="fb-form-num"> <input type="number" min="1" name="financebuddy_installment_total" value="<?= $this->text->e($fb['installment_total']) ?>" class="fb-form-num">
</div> </div>
<p class="form-help"><?= t('Leave the total blank for an open-ended recurring bill.') ?></p> <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> </div>
<?php endif ?> <?php endif ?>

View File

@@ -1 +1 @@
FinanceBuddy v0.3.1 FinanceBuddy v1.0.4