172 lines
6.4 KiB
Go
172 lines
6.4 KiB
Go
package analytics
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
)
|
|
|
|
func (s *Store) Query(ctx context.Context, filter Filter) (Dashboard, error) {
|
|
empty := Dashboard{
|
|
Totals: []Total{}, Previous: []Total{}, Monthly: []Group{},
|
|
Categories: []Group{}, Tags: []Group{}, Merchants: []Group{},
|
|
Accounts: []Group{}, Recurring: []Group{},
|
|
}
|
|
if err := filter.validate(); err != nil {
|
|
return empty, err
|
|
}
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return empty, err
|
|
}
|
|
defer tx.Rollback()
|
|
result := empty
|
|
if result.Totals, err = queryTotals(ctx, tx, filter); err != nil {
|
|
return empty, err
|
|
}
|
|
previous, ok, err := previousFilter(ctx, tx, filter)
|
|
if err != nil {
|
|
return empty, err
|
|
}
|
|
if ok {
|
|
if result.Previous, err = queryTotals(ctx, tx, previous); err != nil {
|
|
return empty, err
|
|
}
|
|
}
|
|
where, args := filter.where()
|
|
prefix := "WITH filtered AS (SELECT t.* FROM transactions t WHERE " + where + ") "
|
|
queries := []struct {
|
|
output *[]Group
|
|
query string
|
|
}{
|
|
{&result.Monthly, `SELECT strftime(booking_date, '%Y-%m'), strftime(booking_date, '%Y-%m'), currency,
|
|
strftime(booking_date, '%Y-%m'), CAST(SUM(amount) AS VARCHAR), COUNT(*)
|
|
FROM filtered GROUP BY currency, strftime(booking_date, '%Y-%m') ORDER BY 4, 3`},
|
|
{&result.Categories, `SELECT c.id, c.name, t.currency, '', CAST(SUM(t.amount) AS VARCHAR), COUNT(*)
|
|
FROM filtered t JOIN category_ancestors ca ON ca.category_id = t.category_id
|
|
JOIN categories c ON c.id = ca.ancestor_id GROUP BY c.id, c.name, t.currency ORDER BY c.id, t.currency`},
|
|
{&result.Tags, `SELECT tag.id, tag.name, t.currency, '', CAST(SUM(t.amount) AS VARCHAR), COUNT(*)
|
|
FROM filtered t JOIN transaction_tags tt ON tt.transaction_id = t.id
|
|
JOIN tags tag ON tag.id = tt.tag_id GROUP BY tag.id, tag.name, t.currency ORDER BY tag.id, t.currency`},
|
|
{&result.Merchants, `SELECT t.merchant_id, COALESCE(m.name, 'No merchant'), t.currency, '', CAST(SUM(t.amount) AS VARCHAR), COUNT(*)
|
|
FROM filtered t LEFT JOIN merchants m ON m.id = t.merchant_id
|
|
GROUP BY t.merchant_id, m.name, t.currency ORDER BY t.merchant_id, t.currency`},
|
|
{&result.Accounts, `SELECT a.id, a.display_name, t.currency, '', CAST(SUM(t.amount) AS VARCHAR), COUNT(*)
|
|
FROM filtered t JOIN accounts a ON a.id = t.account_id
|
|
GROUP BY a.id, a.display_name, t.currency ORDER BY a.id, t.currency`},
|
|
{&result.Recurring, `, spaced AS (
|
|
SELECT *, date_diff('day', LAG(booking_date) OVER (
|
|
PARTITION BY merchant_id, account_id, currency, amount ORDER BY booking_date, id), booking_date) AS gap
|
|
FROM filtered WHERE amount < 0 AND merchant_id <> ''
|
|
), candidates AS (
|
|
SELECT merchant_id, account_id, currency, amount, SUM(amount) AS total, COUNT(*) AS occurrences,
|
|
CASE WHEN MIN(gap) >= 5 AND MAX(gap) <= 9 THEN 'weekly'
|
|
WHEN MIN(gap) >= 26 AND MAX(gap) <= 35 THEN 'monthly'
|
|
WHEN MIN(gap) >= 350 AND MAX(gap) <= 380 THEN 'yearly' ELSE '' END AS cadence
|
|
FROM spaced GROUP BY merchant_id, account_id, currency, amount
|
|
HAVING COUNT(*) >= 3
|
|
)
|
|
SELECT c.merchant_id || ':' || c.account_id || ':' || CAST(c.amount AS VARCHAR),
|
|
m.name, c.currency, c.cadence, CAST(c.total AS VARCHAR), c.occurrences
|
|
FROM candidates c JOIN merchants m ON m.id = c.merchant_id
|
|
WHERE c.cadence <> '' ORDER BY 1, 3`},
|
|
}
|
|
for _, item := range queries {
|
|
groups, err := queryGroups(ctx, tx, prefix+item.query, args)
|
|
if err != nil {
|
|
return empty, fmt.Errorf("query analytics groups: %w", err)
|
|
}
|
|
*item.output = groups
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return empty, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func queryTotals(ctx context.Context, tx *sql.Tx, filter Filter) ([]Total, error) {
|
|
where, args := filter.where()
|
|
rows, err := tx.QueryContext(ctx, `SELECT t.currency,
|
|
CAST(SUM(CASE WHEN t.amount < 0 THEN -t.amount ELSE CAST(0 AS DECIMAL(24,4)) END) AS VARCHAR),
|
|
CAST(SUM(CASE WHEN t.amount > 0 THEN t.amount ELSE CAST(0 AS DECIMAL(24,4)) END) AS VARCHAR),
|
|
CAST(SUM(t.amount) AS VARCHAR)
|
|
FROM transactions t WHERE `+where+` GROUP BY t.currency ORDER BY t.currency`, args...)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("query analytics totals: %w", err)
|
|
}
|
|
defer rows.Close()
|
|
result := []Total{}
|
|
for rows.Next() {
|
|
var total Total
|
|
if err := rows.Scan(&total.Currency, &total.Expenses, &total.Income, &total.Net); err != nil {
|
|
return nil, err
|
|
}
|
|
result = append(result, total)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
func queryGroups(ctx context.Context, tx *sql.Tx, query string, args []any) ([]Group, error) {
|
|
rows, err := tx.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
result := []Group{}
|
|
for rows.Next() {
|
|
var group Group
|
|
if err := rows.Scan(&group.ID, &group.Name, &group.Currency, &group.Period, &group.Amount, &group.Count); err != nil {
|
|
return nil, err
|
|
}
|
|
result = append(result, group)
|
|
}
|
|
return result, rows.Err()
|
|
}
|
|
|
|
// Previous is the immediately preceding inclusive interval of equal length.
|
|
// For one-sided filters, the missing boundary comes from the matching dataset,
|
|
// not the wall clock. All-time queries intentionally have no previous period.
|
|
func previousFilter(ctx context.Context, tx *sql.Tx, filter Filter) (Filter, bool, error) {
|
|
if filter.From == "" && filter.To == "" {
|
|
return filter, false, nil
|
|
}
|
|
if filter.From == "" || filter.To == "" {
|
|
bounds := filter
|
|
bounds.From, bounds.To = "", ""
|
|
where, args := bounds.where()
|
|
var first, last sql.NullString
|
|
if err := tx.QueryRowContext(ctx, "SELECT CAST(MIN(t.booking_date) AS VARCHAR), CAST(MAX(t.booking_date) AS VARCHAR) FROM transactions t WHERE "+where, args...).Scan(&first, &last); err != nil {
|
|
return filter, false, err
|
|
}
|
|
if !first.Valid || !last.Valid {
|
|
return filter, false, nil
|
|
}
|
|
if filter.From == "" {
|
|
filter.From = first.String
|
|
}
|
|
if filter.To == "" {
|
|
filter.To = last.String
|
|
}
|
|
if filter.From > filter.To {
|
|
return filter, false, nil
|
|
}
|
|
}
|
|
from, err := time.Parse(time.DateOnly, filter.From)
|
|
if err != nil {
|
|
return filter, false, err
|
|
}
|
|
to, err := time.Parse(time.DateOnly, filter.To)
|
|
if err != nil {
|
|
return filter, false, err
|
|
}
|
|
days := int((to.Unix()-from.Unix())/86400) + 1
|
|
previousFrom, previousTo := from.AddDate(0, 0, -days), from.AddDate(0, 0, -1)
|
|
// ISO date filters cannot describe dates before year zero.
|
|
if previousFrom.Year() < 0 {
|
|
return filter, false, fmt.Errorf("previous period falls outside supported date range")
|
|
}
|
|
filter.From, filter.To = previousFrom.Format(time.DateOnly), previousTo.Format(time.DateOnly)
|
|
return filter, true, nil
|
|
}
|