PHP/MariaDB ASX portfolio tracker and Australian tax app
Find a file
2026-06-15 16:41:45 +10:00
lib Self-host TOTP enrolment QR codes and fix duplicate CSP img-src 2026-06-12 20:53:30 +10:00
.gitignore gitignore: stop tracking CLAUDE.md — Claude Code project instructions, not for public repo 2026-06-04 14:47:35 +10:00
_footer.php v26.06.15-1 - Shared live price cache across pages 2026-06-15 15:23:57 +10:00
account.php v26.06.12-3 - TOTP replay protection, constant-time comparison, backup codes 2026-06-12 22:02:25 +10:00
admin.php v26.06.12-3 - TOTP replay protection, constant-time comparison, backup codes 2026-06-12 22:02:25 +10:00
annual-report-pdf.php v26.06.03-1 — Income, What-If, Annual PDF, Charts, Help pages; AMIT inline on Tax 2026-06-03 22:10:21 +10:00
auth.php Initial commit — Holdings portfolio tracker 2026-06-01 17:18:28 +10:00
backfill.php Initial commit — Holdings portfolio tracker 2026-06-01 17:18:28 +10:00
cgt-detail.php v26.06.03-1 — Income, What-If, Annual PDF, Charts, Help pages; AMIT inline on Tax 2026-06-03 22:10:21 +10:00
cgt-pdf.php cgt-pdf.php, tax-summary-pdf.php: restructure PDF header/footer — page 1 header suppressed, page number moved to footer centre, generated-by moved to footer right 2026-06-03 20:34:01 +10:00
changelog.php Increase price cache to 1 hour, add manual refresh on dashboard 2026-06-15 15:27:59 +10:00
chart.php v26.06.03-1 — Income, What-If, Annual PDF, Charts, Help pages; AMIT inline on Tax 2026-06-03 22:10:21 +10:00
cost-base.php v26.06.15-1 - Shared live price cache across pages 2026-06-15 15:23:57 +10:00
db.php v26.06.08-1 — DB: TLS connections to MariaDB 2026-06-08 13:32:59 +10:00
edit.php edit: detect ASX public holidays when checking for stale price data 2026-06-09 13:26:34 +10:00
favicon.svg Initial commit — Holdings portfolio tracker 2026-06-01 17:18:28 +10:00
help.php Document price caching and Refresh link in help page 2026-06-15 15:28:56 +10:00
income.php v26.06.03-1 — Income, What-If, Annual PDF, Charts, Help pages; AMIT inline on Tax 2026-06-03 22:10:21 +10:00
index.php Add Market Index links to ticker cells on portfolio page 2026-06-15 16:41:45 +10:00
login.php v26.06.12-3 - TOTP replay protection, constant-time comparison, backup codes 2026-06-12 22:02:25 +10:00
nonce.php Self-host TOTP enrolment QR codes and fix duplicate CSP img-src 2026-06-12 20:53:30 +10:00
performance.php v26.06.03-1 — Income, What-If, Annual PDF, Charts, Help pages; AMIT inline on Tax 2026-06-03 22:10:21 +10:00
prices.php Fix price timestamp to reflect actual fetch time, not page render time 2026-06-15 15:43:55 +10:00
qr.php Release session lock before generating TOTP QR code in qr.php 2026-06-12 21:01:23 +10:00
README.md v26.06.12-3 - TOTP replay protection, constant-time comparison, backup codes 2026-06-12 22:02:25 +10:00
schema.sql v26.06.12-3 - TOTP replay protection, constant-time comparison, backup codes 2026-06-12 22:02:25 +10:00
snapshot.php Initial commit — Holdings portfolio tracker 2026-06-01 17:18:28 +10:00
sold.php v26.06.03-1 — Income, What-If, Annual PDF, Charts, Help pages; AMIT inline on Tax 2026-06-03 22:10:21 +10:00
tax-summary-pdf.php cgt-pdf.php, tax-summary-pdf.php: restructure PDF header/footer — page 1 header suppressed, page number moved to footer centre, generated-by moved to footer right 2026-06-03 20:34:01 +10:00
tax-summary.php v26.06.03-1 — Income, What-If, Annual PDF, Charts, Help pages; AMIT inline on Tax 2026-06-03 22:10:21 +10:00
tax-upload.php Add Raise Cash what-if calculator and matching CGT allocation methods 2026-06-11 17:19:04 +10:00
tax.php v26.06.15-1 - Shared live price cache across pages 2026-06-15 15:23:57 +10:00
totp.php v26.06.12-3 - TOTP replay protection, constant-time comparison, backup codes 2026-06-12 22:02:25 +10:00
transactions.php v26.06.03-1 — Income, What-If, Annual PDF, Charts, Help pages; AMIT inline on Tax 2026-06-03 22:10:21 +10:00
what-if.php v26.06.15-1 - Shared live price cache across pages 2026-06-15 15:23:57 +10:00

Holdings

A self-hosted PHP/MariaDB web app for tracking an Australian Share Exchange (ASX) investment portfolio, calculating rebalancing allocations, monitoring performance, and producing Australian tax reports including CGT workings and AMIT adjustments.


Features

Portfolio & Allocation

  • Live prices fetched in parallel from Yahoo Finance at page load
  • Target allocation percentages per ticker stored in the database
  • Rebalancing calculator: enter a new investment amount and the app allocates it across underweight positions to bring the portfolio back to target weights
  • Current value, units held, and allocation drift displayed per position

Performance

  • Portfolio value history charted from daily snapshots
  • Time-Weighted Return (TWR) calculation that strips out capital flows — new money added or withdrawn doesn't inflate/deflate the return figure
  • ASX 200 benchmark overlay (^AXJO via Yahoo Finance, 6-hour cache) — toggle to compare portfolio TWR against the index for any selected period
  • Outperformance/underperformance (alpha) displayed in chart tooltip
  • Selectable date ranges

Tax Reporting

  • CGT Workings — per-parcel capital gains breakdown for a selected financial year, exportable as a branded PDF
  • Tax Summary — aggregated income and capital gains report for a selected FY, exportable as PDF
  • AMIT upload — upload Betashares AMIT statement PDFs; the app parses tax components (labels 13U, 13C, 13Q, 13R, 13A, 18A, 18H, 20E, 20M, 20O) and adjusts parcel cost bases accordingly
  • Supports FIFO, minimum-CGT, and maximum-CGT disposal methods per sell event
  • CGT 50% discount automatically applied for parcels held longer than 365 days
  • Franking credits, foreign income tax offsets, and DRP units tracked

Transactions

  • View all buy, sell, and distribution reinvestment transactions
  • Upload Betashares CSV transaction exports
  • Manual edit of transaction records
  • Sold securities view showing realised history

Security

  • Username + bcrypt password authentication
  • Optional TOTP two-factor authentication (RFC 6238, SHA1/6-digit/30s) — no external dependencies, pure PHP implementation
  • TOTP replay protection — each accepted code is recorded and rejected if presented again
  • Single-use backup recovery codes for TOTP, generated from the Account page
  • Brute-force lockout: 3 failed attempts (password or TOTP) triggers a per-username cooldown
  • IP-level blocking via admin panel
  • Role-based access: full (read/write) and view (read-only)
  • Sessions expire after 1 hour of inactivity
  • Strict CSP, HSTS, X-Frame-Options, and other security headers set by nginx
  • Database connections use TLS (TLSv1.3) with server certificate verification

Admin

  • User management: create, lock, unlock, reset passwords
  • TOTP management: enable/disable per user
  • IP block management

Tech Stack

Component Detail
Language PHP 8.5
Database MariaDB
Web server Nginx
PDF generation FPDF (bundled in lib/)
Price data Yahoo Finance v8 API (no key required)
Authentication Custom — bcrypt + RFC 6238 TOTP

No Composer dependencies for the main app. FPDF is bundled directly.


Australian Tax Notes

  • Financial year: 1 July 30 June, labelled by the end year. FY26 = 1 Jul 2025 30 Jun 2026.
  • Cost base method: average cost (as required for managed funds / ETFs under Australian tax law).
  • Realised gains: not stored in the database. Recomputed on demand from the full transaction history.
  • AMIT (Attribution Managed Investment Trust): annual tax statements adjust parcel cost bases up or down. The acb_reduction and acb_increase fields in distribution_tax_components update parcels.cost_base_per_unit accordingly.
  • CGT discount: 50% discount applied to net capital gains where the parcel was held for more than 365 days (individual taxpayer rules).

Database Schema

parcels                    One buy lot per row. cost_base_per_unit adjusted by AMIT.
cgt_events                 One row per sell transaction.
parcel_disposals           Parcel-level breakdown of each CGT event (FIFO / min / max CGT).
distribution_tax_components AMIT attribution per distribution payment per ticker.
transactions               Betashares transaction export rows (raw import).
portfolio_snapshots        Daily portfolio totals (value, cost, gain/loss).
holding_prices             Daily per-ticker price and value.
holdings_meta              Target allocation % and display metadata per ticker.
users                      Accounts with bcrypt password, role, TOTP secret.
login_attempts             Per-username attempt log for brute-force lockout.
blocked_ips                IP-level access block, managed via admin panel.

Full DDL is in schema.sql.


Project Structure

index.php            Portfolio overview and rebalancing calculator
performance.php      Portfolio value chart, TWR, ASX 200 benchmark
tax.php              Tax report selector (CGT workings, tax summary)
cgt-detail.php       Per-parcel CGT workings for a selected FY
cgt-pdf.php          CGT workings PDF export
tax-summary.php      Aggregated tax summary for a selected FY
tax-summary-pdf.php  Tax summary PDF export
tax-upload.php       AMIT PDF upload and parsing
transactions.php     Transaction list
edit.php             Transaction editor
sold.php             Sold securities view
snapshot.php         Daily price snapshot cron script
backfill.php         Backfill historical price data
account.php          User account (password, TOTP)
admin.php            Admin panel (users, IPs)
login.php            Authentication
auth.php             Auth helpers
db.php               Database connection
totp.php             Pure-PHP TOTP implementation (RFC 6238)
nonce.php            CSRF nonce helpers
_footer.php          Shared footer with version and licence
changelog.php        Release changelog
config.php           Database credentials (not committed — see setup)
schema.sql           Full database schema
nginx/home           Nginx vhost config
lib/                 FPDF library
cache/               Runtime cache (axjo.json — not committed)

Setup

1. Database

Create a MariaDB database and import the schema:

mysql -u USER -p DB_NAME < schema.sql

2. Configuration

Copy or create config.php with your database credentials:

<?php
return [
    'host'     => 'your-db-host',
    'dbname'   => 'your-db-name',
    'username' => 'your-db-user',
    'password' => 'your-db-password',
    'charset'  => 'utf8mb4',
    'ssl'      => true,   // set false if your DB server does not support SSL
];

config.php is excluded from version control via .gitignore.

3. Cache directory

Create the cache directory with correct ownership:

mkdir -p /var/www/html/holdings/cache
chown www-data:www-data /var/www/html/holdings/cache

4. Nginx

Create a vhost config (e.g. /etc/nginx/sites-available/your-site):

server {
    listen 80;
    server_name your-domain;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    http2 on;

    server_name your-domain;

    root /var/www/html;
    index index.html index.htm index.php;

    ssl_certificate     /etc/letsencrypt/live/your-domain/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/your-domain/privkey.pem;

    add_header Content-Security-Policy "default-src 'none'; script-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self'; style-src 'self' https://fonts.googleapis.com 'unsafe-inline'; font-src 'self' https://fonts.gstatic.com; frame-ancestors 'self'; form-action 'self'" always;
    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;
    add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;

    location ~ \.php$ {
        include snippets/fastcgi-php.conf;
        fastcgi_pass unix:/var/run/php/php8.5-fpm.sock;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        include fastcgi_params;
    }

    location ~* /\.(?!well-known/)[^/]+$ {
        deny all;
    }

    location ~ /(\.git|\.svn|\.hg|README\.md|LICENSE|Makefile|composer\.json|package\.json|node_modules|config\.ru|Gemfile|Rakefile|Capfile|unicorn\.rb|nginx\.conf|web\.config) {
        deny all;
    }

    location ~* (\.(bak|conf|old|orig|php\d?|ini|log|sql)(\.|$)|(/\.|etc/passwd|proc/self/environ|wp-config\.php)) {
        deny all;
    }

    location / {
        try_files $uri $uri/ /index.php?$args;
    }
}

Enable the site and reload:

ln -s /etc/nginx/sites-available/your-site /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginx

5. First run

Visit /holdings/login.php — on first load with no users in the database, a setup form prompts you to create the initial admin account.

6. Snapshot cron

Add a cron job to record daily portfolio snapshots (runs after market close):

30 16 * * 1-5 /usr/bin/php /var/www/html/holdings/snapshot.php >> /var/log/holdings-snapshot.log 2>&1

Licence

© David Klaverstyn. Licensed under CC BY-NC-SA 4.0.