App-GnuCash-MembershipUtils
view release on metacpan or search on metacpan
lib/App/GnuCash/MembershipUtils.pm view on Meta::CPAN
##----------------------------------------------------------------------------
## :mode=perl:indentSize=2:tabSize=2:noTabs=true:
##****************************************************************************
## NOTES:
## * Before comitting this file to the repository, ensure Perl Critic can be
## invoked at the HARSH [3] level with no errors
##****************************************************************************
package App::GnuCash::MembershipUtils;
use strict;
use warnings;
use YAML::XS;
use GnuCash::Schema;
use DBD::SQLite;
use Exporter qw( import );
use File::Slurp;
use Data::Dump qw( pp );
use Readonly;
use Carp qw( carp );
our @EXPORT_OK = qw(
db_accounts_to_hash
get_all_members
get_config
get_gnucash_filename
max_length
open_gnucash
title_case
validate_accounts_in_config
);
our %EXPORT_TAGS = (
all => [ @EXPORT_OK ],
config => [ qw(
get_config
get_gnucash_filename
validate_accounts_in_config
)],
db => [ qw(
db_accounts_to_hash
get_all_members
open_gnucash
)],
other => [qw(
max_length
title_case
)],
);
Readonly::Scalar my $EXPECTED_INVOICE_ACCOUNT_TYPE => "RECEIVABLE";
Readonly::Scalar my $EXPECTED_ITEM_ACCOUNT_TYPE => "INCOME";
Readonly::Hash my %DEFAULT_CONFIG_VALUES => (
format => "us",
memo => "Monthly membership dues",
);
## Version string
our $VERSION = qq{0.01};
=head1 NAME
App::GnuCash::MembershipUtils - A group of perl modules and scripts to help in
using L<GnuCash|https://www.gnucash.org/> for membership.
=head1 DESCRIPTION
App::GnuCash::MembershipUtils is a group of perl modules and scripts to help in
using L<GnuCash|https://www.gnucash.org/> for membership.
It assumes all customers are members, and uses the customer "notes" field to
determine what type of membership for each member / customer.
=head1 VERSION
Version 0.01
=head1 SYNOPSIS
use App::GnuCash::MembershipUtils qw( :all );
=cut
=head1 PUBLIC FUNCTIONS
=cut
=head2 get_config($filename)
my ($error, $config) = get_config($filename);
Returns an C<$error> if the given C<$filename> cannot be opened.
If there is no C<$error> then C<$config> will be a HASHREF with the
config.
=cut
sub get_config {
my $filename = shift // "";
my $debug = shift // 0;
$filename =~ s/^\s+|\s+$//g;
return ("filename cannot be empty", undef) unless length($filename);
return ("filename '$filename' does not exist", undef) unless -f $filename;
$YAML::XS::ForbidDuplicateKeys = 1;
my $config = Load(scalar(read_file($filename)));
my @errors;
# Now we have a hash, let's make sure it has what we need
if (exists($config->{MembershipTypes})) {
if (exists($config->{MembershipTypes}{default})) {
my $error = _validate_membership_type_config($config->{MembershipTypes}{default}, 1);
if ($error) {
push(@errors, "The 'MembershipTypes.default' section $error");
}
if (my $others = $config->{MembershipTypes}{others}) {
my $idx;
for my $type (@$others) {
$idx++;
if (my $error = _validate_membership_type_config($type)) {
push(@errors, "The 'MembershipTypes.others' entry $idx $error");
}
}
}
} else {
push(@errors, "Missing 'MembershipTypes.default' section");
}
} else {
push(@errors, "Missing 'MembershipTypes' section");
}
if (@errors) {
my $error = sprintf(
"The config has the following %s\n * %s",
((scalar(@errors) == 1) ? "error" : "errors"),
join("\n * ", @errors),
);
return ($error, $config);
}
if ($debug) {
printf STDERR "Before applying defaults\n%s\n", pp( $config );
}
## Load defaults
for my $key (keys %DEFAULT_CONFIG_VALUES) {
## Only load them if the current key is undefined
$config->{GnuCash}{$key} //= $DEFAULT_CONFIG_VALUES{$key};
}
if ($debug) {
printf STDERR "After applying defaults\n%s\n", pp( $config );
}
return (undef, $config);
}
=head2 get_gnucash_filename($override, $config)
my $filename = get_filename_from_config($override, $config);
Returns the C<GnuCash.file> from the given C<$config>.
=cut
sub get_gnucash_filename {
my $override = shift // "";
my $config = shift // {};
return $override if length($override);
if (exists($config->{GnuCash})) {
return $config->{GnuCash}{file} // "";
}
return "";
}
=head2 open_gnucash($filename)
my ($error, $schema) = open_gnucash($filename);
Returns an C<$error> if the given C<$filename> cannot be opened.
If C<$error> is undef, then C<$schema> will be a C<GnuCash::Schema>
lib/App/GnuCash/MembershipUtils.pm view on Meta::CPAN
Returns the maximum length of the strings in the arguments provided.
=cut
sub max_length {
my @strings = @_;
my $max = 0;
for my $string (@strings) {
my $len = length($string);
$max = $len if ($len > $max);
}
return $max;
}
=head2 db_accounts_to_hash($schema)
my $accounts = db_accounts_to_hash($schema);
Returns a HASHREF whose keys are the complete name of each
account, and whose keys are as follows:
=over
=item account_type
=item hidden
=item placeholder
=back
=cut
sub db_accounts_to_hash {
my $schema = shift;
my %accounts;
my $rs = $schema->resultset('Account')->search(
{
# parent_guid IS NOT NULL
parent_guid => { '!=' => undef, },
},
{
columns => [qw( name account_type hidden placeholder guid parent_guid )],
}
);
while (my $account = $rs->next) {
my $full_name = $account->complete_name;
$accounts{$full_name} = { $account->get_columns };
}
return \%accounts;
}
=head2 validate_accounts_in_config($args)
my ($errors, $warnings) = validate_accounts_in_config($args);
warn $warnings if ($warnings);
die $errors if ($errors);
Accepts a HASHREF of C<$args> whose keys are as follows:
=over
=item config
=item schema
=back
Returns C<$errors> which is a string indicating fatal errors, and
C<$warnings> which is a non-fatal error.
=cut
sub validate_accounts_in_config {
my $args = shift;
my $schema = delete $args->{schema};
my $config = delete $args->{config};
my $debug = delete $args->{debug} // 0;
my $accounts = db_accounts_to_hash($schema);
my @errors;
my @warnings;
if (exists($config->{GnuCash})) {
my $config_account = $config->{GnuCash}{account};
if (my $gc_account = $accounts->{$config_account}) {
if ($gc_account->{account_type} ne $EXPECTED_INVOICE_ACCOUNT_TYPE) {
_warning_account_type(
\@warnings,
"'GnuCash.account'",
$gc_account,
$EXPECTED_INVOICE_ACCOUNT_TYPE,
);
} elsif ($gc_account->{hidden} || $gc_account->{placeholder}) {
_error_hidden_or_placeholder_account(
\@errors,
"'GnuCash.account'",
);
}
} else {
_error_invalid_account(\@errors, "'GnuCash.account'");
}
} else {
push(@errors, "Missing 'GnuCash' section");
}
if (exists($config->{MembershipTypes})) {
if (exists($config->{MembershipTypes}{default})) {
my $error = _validate_membership_type_config($config->{MembershipTypes}{default}, 1);
if ($error) {
push(@errors, "The 'MembershipTypes.default' section $error");
} else {
my $config_account = $config->{MembershipTypes}{default}{account};
if (my $gc_account = $accounts->{$config_account}) {
if ($gc_account->{account_type} ne $EXPECTED_ITEM_ACCOUNT_TYPE) {
_warning_account_type(
\@warnings,
"'MembershipTypes.default.account'",
$gc_account,,
$EXPECTED_ITEM_ACCOUNT_TYPE,
);
} elsif ($gc_account->{hidden} || $gc_account->{placeholder}) {
_error_hidden_or_placeholder_account(
\@errors,
"'MembershipTypes.default'",
);
}
} else {
_error_invalid_account(\@errors, "'MembershipTypes.default'");
}
}
if (my $others = $config->{MembershipTypes}{others}) {
my $idx;
for my $type (@$others) {
$idx++;
my $section = "'MembershipTypes.others' entry $idx";
my $config_account = $type->{account};
if (my $gc_account = $accounts->{$config_account}) {
if ($gc_account->{account_type} ne $EXPECTED_ITEM_ACCOUNT_TYPE) {
_warning_account_type(
\@warnings,
$section,
$gc_account,
$EXPECTED_ITEM_ACCOUNT_TYPE,
);
} elsif ($gc_account->{hidden} || $gc_account->{placeholder}) {
_error_hidden_or_placeholder_account(
\@errors,
$section,
);
}
} else {
_error_invalid_account(\@errors, $section);
}
}
}
} else {
push(@errors, "Missing 'MembershipTypes.default' section");
}
} else {
push(@errors, "Missing 'MembershipTypes' section");
}
my $error_string;
if (@errors) {
$error_string = sprintf(
"The config has the following fatal %s\n * %s",
((scalar(@errors) == 1) ? "error" : "errors"),
join("\n * ", @errors),
);
}
my $warning_string;
if (@warnings) {
$warning_string = sprintf(
"The config has the following non-fatal %s\n * %s",
((scalar(@warnings) == 1) ? "error" : "errors"),
join("\n * ", @warnings),
);
}
return ($error_string, $warning_string);
}
sub _warning_account_type {
my $warnings = shift;
my $section = shift;
my $account = shift;
my $type = shift;
push(
@$warnings,
sprintf(
"The %s account type is '%s' but should be '%s'",
$section,
$account->{account_type},
$type,
)
);
}
sub _error_hidden_or_placeholder_account {
my $errors = shift;
my $section = shift;
push(
@$errors,
sprintf(
"The %s account cannot be hidden, or a placeholder.",
$section,
)
);
}
sub _error_invalid_account {
my $errors = shift;
my $section = shift;
push(
@$errors,
sprintf(
"The %s account could not be found.",
$section,
)
);
}
sub _validate_membership_type_config {
my $config = shift // {};
my $default_type = shift // 0;
my @required_keys = qw( account amount name );
push(@required_keys, qw( match )) unless $default_type;
my @missing;
my $invalid_amount;
for my $key (@required_keys) {
if (exists($config->{$key}) && defined($config->{$key})) {
if ($key eq 'amount') {
my $value = $config->{$key};
$invalid_amount = $value unless ($value =~ m/^\d+\.\d{2}$/);
}
} else {
push(@missing, $key)
}
}
# Now generate the return string
my $error = (
( @missing
? sprintf("is missing the following: '%s'", join("', '", @missing))
: ""
)
);
if ($invalid_amount) {
$error .= " and " if ($error);
$error .= sprintf(
"the 'amount' '%s' is invalid, and should be in the form 'X.XX'",
$invalid_amount,
);
}
return $error;
}
=head1 CONFIG FILE FORMAT
This module supports reading a L<YAML|https://yaml.org/> based config file.
=head2 SAMPLE
Here is a sample config file:
---
GnuCash:
file: /path/to/organization.gnucash
format: US
memo: Monthly membership dues
account: Assets:Accounts Receivable
MembershipTypes:
default:
name: Standard Membership
account: Income:Membership Dues
amount: 30.00
others:
- name: Special Membership
match: Special
account: Income:Membership Dues
( run in 1.174 second using v1.01-cache-2.11-cpan-437f7b0c052 )