package Finance::Tiller2QIF;
# ABSTRACT: Convert Tiller CSV exports to QIF format
$Finance::Tiller2QIF::VERSION = '1.00'; # TRIAL
use v5.34;
use strict;
use warnings;
use utf8;
use warnings FATAL => 'utf8';
use open ':std', ':encoding(UTF-8)';

use feature qw/signatures postderef/;

use Path::Tiny;
use Pod::Usage;
use Getopt::Long::Descriptive;
use Cpanel::JSON::XS;
use Finance::Tiller2QIF::ReadCSV;
use Finance::Tiller2QIF::Map;
use Finance::Tiller2QIF::Util;
use Finance::Tiller2QIF::WriteQIF;


# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------

sub ingest (%options) {
  Finance::Tiller2QIF::ReadCSV::Ingest( $options{input}, $options{db} );
}

sub apply_map (%options) {
  Finance::Tiller2QIF::Map::Map( $options{db}, $options{mapfile}, $options{verbose} );
}

sub emit (%options) {
  Finance::Tiller2QIF::WriteQIF::Emit( $options{db}, $options{output} );
}

sub run (%options) {
  ingest( %options );
  apply_map( %options );
  emit( %options );
}

# ---------------------------------------------------------------------------
# CLI entry point — called by bin/tiller2qif
# ---------------------------------------------------------------------------

my $VALID_COMMANDS = join '|', qw(run ingest map emit newdb newconfig help);

my $hlpmsg = qq{

tiller2qif <command> %o

Commands: ${VALID_COMMANDS}

For more detailed help use tiller2qif help

};

sub run_cli {
  my $cmd = '';
  # uncoverable branch true
  # uncoverable branch false
  if ( @ARGV && $ARGV[0] !~ /^-/ ) {
    $cmd = lc shift @ARGV;
  }

  my ( $opt, $usage ) = describe_options(
    $hlpmsg,
    [ 'config|c=s',   "JSON config file"                                     ],
    [ 'input|i=s',    "CSV file to read      (ingest, run)"                  ],
    [ 'output|o=s',   "QIF file to write     (emit, run)"                    ],
    [ 'db|d=s',       "SQLite database file  (all commands except newconfig)" ],
    [ 'mapfile|f=s', "Category mapping file (map, run — optional)"          ],
    [ 'verbose|v',    "Print detailed progress information"                  ],
    [],
    [ 'help|h',       "Print usage and exit", { shortcircuit => 1 }           ],
  );

  if ( !$cmd || $cmd eq 'help'  ) {
    pod2usage( -verbose => 2, -exitval => 'NOEXIT',
               -output  => \*STDOUT, -input => __FILE__ );
    return;
  }

  die "Unknown command '$cmd'. Valid commands: $VALID_COMMANDS\n"
    unless $cmd =~ /^(?:$VALID_COMMANDS)$/;

  if ( $cmd eq 'newconfig' ) {
    die "newconfig requires --config\n" unless $opt->config;
    say "Creating config file: " . $opt->config if $opt->verbose;
    Finance::Tiller2QIF::Util::InitConfig( $opt->config );
    say "Config file created: " . $opt->config;
    return;
  }

  my %options = ();
  if ( $opt->config ) {
    my $config = Cpanel::JSON::XS->new->utf8->relaxed->decode(
      path( $opt->config )->slurp_utf8
    );
    %options = %$config;
  }
  for my $key (qw( input output db verbose mapfile )) {
    my $val = $opt->$key();
    $options{$key} = $val if defined $val;
  }

  if ( $cmd eq 'newdb' ) {
    die "newdb requires --db\n" unless $options{db};
    say "Creating database: " . $options{db} if $opt->verbose;
    Finance::Tiller2QIF::Util::InitDB( $options{db} );
    say "Database initialized: " . $options{db};
    return;
  }

  my @missing;
  push @missing, '--input'  if $cmd =~ /^(?:ingest|run)$/ && !$options{input};
  push @missing, '--db'     if !$options{db};
  push @missing, '--output' if $cmd =~ /^(?:emit|run)$/   && !$options{output};

  if (@missing) {
    die "tiller2qif $cmd: missing required option(s): "
      . join( ', ', @missing ) . "\n";
  }

  if ( $cmd =~ /^(?:ingest|run)$/ ) {
    say "Ingesting CSV: " . $options{input} if $opt->verbose;
    my $newitems = ingest( %options );
    say "Ingested: ${newitems} transactions from: " . $options{input};
  }

  if ( $cmd =~ /^(?:map|run)$/ ) {
    if ( $options{mapfile} ) {
      say "Applying mapping: " . $options{mapfile} if $opt->verbose;
      apply_map( %options );
      say "Mapping applied: " . $options{mapfile};
    } else {
      say "No mapfile provided, skipping mapping phase";
    }
  }

  if ( $cmd =~ /^(?:emit|run)$/ ) {
    say "Writing QIF: " . $options{output} if $opt->verbose;
    my $changed = emit( %options );
    say "QIF written: ${\ $options{output}}, ${changed} records emitted!";
  }
}

1;

__END__

=pod

=encoding UTF-8

=head1 NAME

Finance::Tiller2QIF - Convert Tiller CSV exports to QIF format

=head1 VERSION

version 1.00

=head1 SYNOPSIS

  # Command-line
  tiller2qif run --input export.csv --db tiller.sqlite3 \
                 --output import.qif [--mapfile mapping.txt]

  # Programmatic
  use Finance::Tiller2QIF;

  Finance::Tiller2QIF::ingest( input => 'export.csv', db => 'tiller.sqlite3' );
  Finance::Tiller2QIF::apply_map( db => 'tiller.sqlite3', mapfile => 'mapping.txt' );
  Finance::Tiller2QIF::emit( db => 'tiller.sqlite3', output => 'import.qif' );

=head1 DESCRIPTION

Tiller Money (tillerapp.com) aggregates bank and credit-card transactions
into a Google Sheet and lets you export a CSV. This module ingests that CSV
into a SQLite database, optionally applies a category-mapping file to
translate Tiller's auto-assigned categories to match your accounts/categories, then emits a QIF file ready for import.

The three phases can be run individually or together:

=over 4

=item B<ingest> — parse the CSV and load rows into the SQLite database.

=item B<map> — apply a user-supplied mapping file that rewrites categories,
suppresses duplicates (card-payment credits), and assigns destination accounts.

=item B<emit> — read unexported rows from the database and write a QIF file.

=back

=head1 NAME

Finance::Tiller2QIF

=head2 OVERVIEW

Convert Tiller CSV exports to QIF for import into Financial software like GnuCash, KMyMoney, Quicken, HomeBank, Money Manager Ex and many others.

=head1 INSTALLATION

=head2 From CPAN

  cpan Finance::Tiller2QIF
  # or
  cpanm Finance::Tiller2QIF

=head2 Perl Dependencies

The following Perl modules are required:

=over 4

=item * Path::Tiny

=item * Text::CSV

=item * Mojo::SQLite

=item * DateTime::Format::Flexible

=item * Cpanel::JSON::XS

=item * Getopt::Long::Descriptive

=back

=head3 On Debian/Ubuntu:

All of Tiller2QIF’s dependencies are available through package management if you need to install to system Perl.

  sudo apt install libpath-tiny-perl libtext-csv-perl \
    libmojo-sqlite-perl libcpanel-json-xs-perl libdatetime-format-flexible-perl
  sudo cpan install Finance::Tiller2QIF

=head1 CLI COMMANDS

=over 4

=item B<run> -- ingest, map, and emit in one step

  tiller2qif run --input export.csv --db tiller.sqlite3 \
                 --output import.qif [--mapfile mapping.txt]

=item B<ingest> -- load CSV into the database

  tiller2qif ingest --input export.csv --db tiller.sqlite3

=item B<map> -- apply category mapping rules

  tiller2qif map --db tiller.sqlite3 [--mapfile mapping.txt]

=item B<emit> -- write QIF from the database

  tiller2qif emit --db tiller.sqlite3 --output import.qif

=item B<newdb> -- initialise a new SQLite database

  tiller2qif newdb --db tiller.sqlite3

=item B<newconfig> -- create a starter config file

  tiller2qif newconfig [--config ~/.config/tiller2qif.conf]

=back

=head1 OPTIONS

    "input":    "~/Downloads/mytillerdump.csv",
    "output":   "/tmp/tillerout.qif",
    "db":       "~/.data/tiller2qif.sqlite3",
    "mapfile": "~/.config/tiller.mapping"
  }

Pass the config file with C<--config>.  Command-line options override config
file values.

=over 4

=item B<--input> CSV export from Tiller.

=item B<--output> QIF file to create.

=item B<--db> SQLite database file used to store and transform transactions between phases.

=item B<--mapfile> File containing category mapping rules.  Optional; omitting it passes
transactions through with their original Tiller categories.

=back

=head1 MAPPING FILE

The mapping file controls how Tiller categories are translated into destination
account or category names and which transactions to suppress.  Each non-comment
line has the form:

  [AccountFilter] field | pattern | destination

Lines beginning with C<#> and blank lines are ignored.  Rules are evaluated in
order; the first matching rule wins and no further rules are checked for that
transaction.

=over 4

=item B<AccountFilter> (optional) — a Perl regex in square brackets that
restricts the rule to transactions on matching accounts.  Alternation works
naturally: C<[Checking|Savings]>. Omit to match all accounts.

=item B<field> — the transaction field to test: C<category>, C<payee>,
C<memo>, C<date>, or C<amount>.

=item B<pattern> — a Perl regex applied case-insensitively to the field value.

For a simple pattern containing no C<|>, write it as-is:

  payee | Starbucks | Expenses:Coffee

To use regex alternation (matching either of several values), enclose the
pattern in forward slashes:

  payee | /Starbucks|Dunkin/ | Expenses:Coffee

To match a literal pipe character in the data, escape it with a backslash:

  payee | Cash\|App | Expenses:Transfers

NULL field values (e.g. a transaction with no memo) skip the rule rather than
matching an empty string.

=item B<destination> — the target account or category name, or one of the
special keywords:

=over 4

=item C<source> — keep the original Tiller category unchanged.

=item C<blank> — emit no category field in the QIF output.

=item C<skip> — exclude the transaction from QIF output entirely (useful for
suppressing the credit-side of card payments that appear in both accounts).

=back

For double-entry programs such as GnuCash, destination is a full account name
(e.g. C<Expenses:Groceries>).  For single-entry programs such as Quicken it is
a category name.

=back

The optional C<default> line sets the fallback for transactions that match no
rule.  It must appear as the last non-comment line:

  default | source

If the C<default> line is omitted, unmatched transactions behave as
C<default | source>.

=head2 EXAMPLES

=over 4

=item * Map by category

  category | Groceries | Expenses:Groceries

=item * Map by payee with alternation (slash-delimited pattern)

  payee | /Starbucks|Dunkin/ | Expenses:Coffee

=item * Map by payee, scoped to one account

  [Checking] payee | Payroll | Income:Salary

=item * Scope to multiple accounts using alternation in the account filter

  [Checking|Savings] category | Transfer | skip

=item * Skip card-payment credits on the card account

  [CapitalOne] category | Transfer | skip

=item * Match a literal pipe character in a payee name

  payee | Cash\|App | Expenses:Transfers

=item * Suppress category in QIF (no L field)

  category | Miscellaneous | blank

=item * Default: leave unmatched transactions with their Tiller category

  default | source

=back

=head1 SEE ALSO

L<Finance::Tiller2QIF::ReadCSV>, L<Finance::Tiller2QIF::Map>,
L<Finance::Tiller2QIF::WriteQIF>, L<Finance::Tiller2QIF::Util>

=head1 AUTHOR

John Karr E<lt>brainbuz@cpan.orgE<gt>

=head1 LICENSE

GPL version 3 or later.

=head1 AUTHOR

brainbuz@cpan.org

=head1 COPYRIGHT AND LICENSE

This software is Copyright (c) 2026 by Your Name.

This is free software, licensed under:

  The GNU General Public License, Version 3, June 2007

=cut
