v3.6: products overhaul
New features, new documentation.
This commit is contained in:
parent
e748566913
commit
da523f8daa
7 changed files with 296 additions and 35 deletions
79
UPGRADING.md
79
UPGRADING.md
|
@ -1,3 +1,82 @@
|
|||
# (2022-12-25) RevBank 3.6
|
||||
|
||||
## Update your `revbank.plugins`
|
||||
|
||||
The `edit` command is now in its own plugin, so that it can be disabled (this
|
||||
has been requested several times). To keep the ability to edit the products
|
||||
list from within RevBank, add `edit` to `revbank.plugins`.
|
||||
|
||||
## Check your `revbank.products`
|
||||
|
||||
There's new syntax for `revbank.products`: addons. Check that your lines don't
|
||||
have `+foo` at the end, where `foo` can be anything.
|
||||
|
||||
Also check that you don't have any product ids that start with `+`; those can
|
||||
no longer be entered as this syntax now has special semantics.
|
||||
|
||||
So these don't work as before:
|
||||
|
||||
example_id 1.00 Example product +something
|
||||
+something 1.00 Product id that starts with plus
|
||||
example,+alias 1.00 Alias that starts with plus
|
||||
|
||||
These will keep working as they were:
|
||||
|
||||
example_id1 1.00 Example product+something
|
||||
example_id2 1.00 Example product + something
|
||||
more_stuff 1.00 Example product with +something but not at the end
|
||||
bbq 1.00 3+ pieces of meat
|
||||
|
||||
## New features in `products` plugin
|
||||
|
||||
There are several new features that you may wish to take advantage of. By
|
||||
combining the new features, powerful things can be done that previously
|
||||
required custom plugins.
|
||||
|
||||
The syntax for `revbank.products` has become complex. Please refer to the new
|
||||
documentation in [products.pod](plugins/products.pod) for details.
|
||||
|
||||
### Negative prices (add money to account)
|
||||
|
||||
Support for non-positive prices was requested several times over the years and
|
||||
has now finally been implemented.
|
||||
|
||||
It's now possible to have a product with a negative amount, which when "bought"
|
||||
will cause the user to receive money instead of spending it.
|
||||
|
||||
### Product addons
|
||||
|
||||
It is now possible to add products to products, which is done by specifying
|
||||
`+foo` at the end of a product description, where `foo` is the id of another
|
||||
product. This can be used for surcharges and discounts, or for bundles of
|
||||
products that can also be bought individually.
|
||||
|
||||
### Eplicit contra accounts
|
||||
|
||||
By default, products sold via the `products` plugin, are accounted on the
|
||||
`+sales/products` contra account. This can now be overridden by specifying
|
||||
`@accountname` after the price in `revbank.products`. For example,
|
||||
`1.00@+sales/products/specificcategory`. While this will mess up your tidy
|
||||
columns, you may be able to get rid of a bunch of custom plugins now.
|
||||
|
||||
When the specified contra account is a regular account (does not start with `+`
|
||||
or `-`), this works similar to the `market` plugin, but without any commission
|
||||
for the organization.
|
||||
|
||||
## Pfand plugin: gone
|
||||
|
||||
The `pfand` plugin, that was originally written as a proof-of-concept demo, has
|
||||
been removed without deprecation cycle. To my knowledge, nobody uses this
|
||||
plugin. If you did use it, just grab the old version from git. Please let me
|
||||
know about your usecase!
|
||||
|
||||
The introduction of beverage container deposits in The Netherlands has
|
||||
triggered reevaluation, and several things about that plugin were wrong,
|
||||
including the condescending comments that bottle deposits for small bottles
|
||||
would be crazy or wouldn't make sense in a self-service environment. RevBank
|
||||
was too limited to support it properly, but I think current RevBank fulfills
|
||||
all requirements for making a better, proper pfand plugin.
|
||||
|
||||
# (2022-08-30) RevBank 3.5
|
||||
|
||||
RevBank now has a simple built-in text editor for products and market;
|
||||
|
|
|
@ -18,7 +18,7 @@ sub new($class, $amount, $description, $attributes = {}) {
|
|||
description => $description,
|
||||
attributes => { %$attributes },
|
||||
user => undef,
|
||||
contras => [],
|
||||
contras => [], # infos + contras
|
||||
caller => List::Util::first(sub { !/^RevBank::Cart/ }, map { (caller $_)[3] } 1..10)
|
||||
|| (caller 1)[3],
|
||||
};
|
||||
|
@ -43,6 +43,22 @@ sub add_contra($self, $user, $amount, $description) {
|
|||
return $self; # for method chaining
|
||||
}
|
||||
|
||||
sub add_info($self, $amount, $description) {
|
||||
$amount = RevBank::Amount->parse_string($amount) if not ref $amount;
|
||||
|
||||
$description =~ s/\$you/$self->{user}/g if defined $self->{user};
|
||||
|
||||
push @{ $self->{contras} }, {
|
||||
user => undef,
|
||||
amount => $amount, # should usually have SAME sign (+/-)
|
||||
description => $description,
|
||||
};
|
||||
|
||||
$self->attribute('changed', 1);
|
||||
|
||||
return $self; # for method chaining
|
||||
}
|
||||
|
||||
sub has_attribute($self, $key) {
|
||||
return (
|
||||
exists $self->{attributes}->{$key}
|
||||
|
@ -73,7 +89,7 @@ sub multiplied($self) {
|
|||
|
||||
sub contras($self) {
|
||||
# Shallow copy suffices for now, because there is no depth.
|
||||
return map +{ %$_ }, @{ $self->{contras} };
|
||||
return map +{ %$_ }, grep defined $_->{user}, @{ $self->{contras} };
|
||||
}
|
||||
|
||||
sub as_printable($self) {
|
||||
|
@ -85,16 +101,19 @@ sub as_printable($self) {
|
|||
# positive numbers.
|
||||
push @s, sprintf "%8s %s", $self->{amount}->string_flipped, $self->{description};
|
||||
|
||||
for my $c ($self->contras) {
|
||||
next if RevBank::Users::is_hidden($c->{user}) and not $ENV{REVBANK_DEBUG};
|
||||
|
||||
for my $c (@{ $self->{contras} }) {
|
||||
my $description;
|
||||
if (defined $c->{user}) {
|
||||
next if RevBank::Users::is_hidden($c->{user}) and not $ENV{REVBANK_DEBUG};
|
||||
$description = join " ", ($c->{amount}->cents > 0 ? "->" : "<-"), $c->{user};
|
||||
} else {
|
||||
$description = $c->{description};
|
||||
}
|
||||
push @s, sprintf(
|
||||
"%11s %s %s",
|
||||
$c->{amount}->abs->string,
|
||||
($c->{amount}->cents > 0 ? "->" : "<-"),
|
||||
$c->{user}
|
||||
"%11s %s",
|
||||
($self->{amount} > 0 ? $c->{amount}->string_flipped("") : $c->{amount}->string),
|
||||
$description
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
push @s, "}" if $self->multiplied;
|
||||
|
@ -109,6 +128,7 @@ sub as_loggable($self) {
|
|||
|
||||
my @s;
|
||||
for ($self, @{ $self->{contras} }) {
|
||||
next if not defined $_->{user};
|
||||
my $total = $quantity * $_->{amount};
|
||||
|
||||
my $description =
|
||||
|
|
13
plugins/edit
Normal file
13
plugins/edit
Normal file
|
@ -0,0 +1,13 @@
|
|||
#!perl
|
||||
|
||||
HELP "edit" => "Edit product list";
|
||||
|
||||
my $filename = 'revbank.products';
|
||||
|
||||
sub command :Tab(edit) ($self, $cart, $command, @) {
|
||||
$command eq 'edit' or return NEXT;
|
||||
|
||||
require RevBank::TextEditor;
|
||||
RevBank::TextEditor::edit($filename);
|
||||
return ACCEPT;
|
||||
}
|
109
plugins/products
109
plugins/products
|
@ -1,36 +1,58 @@
|
|||
#!perl
|
||||
|
||||
HELP1 "<productID>" => "Add a product to pending transaction";
|
||||
HELP "edit" => "Edit product list";
|
||||
|
||||
my $filename = 'revbank.products';
|
||||
|
||||
sub _read_products() {
|
||||
my %products;
|
||||
my $line = 0;
|
||||
for (slurp $filename) {
|
||||
$line++;
|
||||
/^\s*#/ and next;
|
||||
/\S/ or next;
|
||||
chomp;
|
||||
my ($ids, $p, $d) = split " ", $_, 3;
|
||||
|
||||
my ($ids, $p, $desc) = split " ", $_, 3;
|
||||
my @ids = split /,/, $ids;
|
||||
|
||||
$products{ $_ } = { id => $ids[0], price => $p, description => $d}
|
||||
for @ids;
|
||||
$p ||= "invalid";
|
||||
$desc ||= "(no description)";
|
||||
|
||||
my ($price, $contra) = split /\@/, $p, 2;
|
||||
|
||||
my $sign = $price =~ s/^-// ? -1 : 1;
|
||||
|
||||
$price = eval { parse_amount($price) };
|
||||
if (not defined $price) {
|
||||
warn "Invalid price for '$ids[0]' at $filename line $line.\n";
|
||||
next;
|
||||
}
|
||||
|
||||
my @addons;
|
||||
unshift @addons, $1 while $desc =~ s/\s+ \+ (\S+) \s*$//x;
|
||||
|
||||
|
||||
$products{$_} = {
|
||||
id => $ids[0],
|
||||
price => $sign * $price,
|
||||
description => $desc,
|
||||
contra => $contra || '+sales/products',
|
||||
addons => \@addons,
|
||||
line => $line,
|
||||
} for @ids;
|
||||
}
|
||||
|
||||
return \%products;
|
||||
}
|
||||
|
||||
sub command :Tab(edit,&tab) ($self, $cart, $command, @) {
|
||||
if ($command eq 'edit') {
|
||||
require RevBank::TextEditor;
|
||||
RevBank::TextEditor::edit($filename);
|
||||
return ACCEPT;
|
||||
}
|
||||
sub command :Tab(&tab) ($self, $cart, $command, @) {
|
||||
$command =~ /\S/ or return NEXT;
|
||||
$command =~ /^\+/ and return NEXT;
|
||||
|
||||
my $product = _read_products->{ $command } or return NEXT;
|
||||
|
||||
my $price = parse_amount( $product->{price} ) or return NEXT;
|
||||
my $products = _read_products;
|
||||
my $product = $products->{ $command } or return NEXT;
|
||||
my $price = $product->{price};
|
||||
|
||||
my @existing = grep {
|
||||
$_->attribute('plugin') eq $self->id and
|
||||
|
@ -42,17 +64,56 @@ sub command :Tab(edit,&tab) ($self, $cart, $command, @) {
|
|||
return ACCEPT;
|
||||
}
|
||||
|
||||
$cart
|
||||
->add(
|
||||
-$price,
|
||||
$product->{description},
|
||||
{ product_id => $product->{id}, plugin => $self->id }
|
||||
)
|
||||
->add_contra(
|
||||
"+sales/products",
|
||||
+$price,
|
||||
"\$you bought $product->{description}"
|
||||
);
|
||||
my $total = $price;
|
||||
my $contra_desc = "\$you bought $product->{description}";
|
||||
|
||||
my @addons = @{ $product->{addons} };
|
||||
my @infos = @addons ? (
|
||||
$price->cents > 0 ? ([ $price, "Product" ])
|
||||
: $price->cents < 0 ? ([ $price, "Reimbursement" ])
|
||||
: () # price == 0: only addons
|
||||
) : ();
|
||||
|
||||
my @contras;
|
||||
my %ids_seen = ($product->{id} => 1);
|
||||
|
||||
while (my $addon_id = shift @addons) {
|
||||
$addon_id = "+$addon_id" if exists $products->{"+$addon_id"};
|
||||
|
||||
if ($ids_seen{$addon_id}++) {
|
||||
return REJECT, "Infinite addons are not supported.";
|
||||
}
|
||||
|
||||
my $addon = $products->{$addon_id}
|
||||
or return REJECT, "Addon '$addon_id' does not exist.";
|
||||
|
||||
$total += $addon->{price};
|
||||
|
||||
push @infos, [ $addon->{price}, $addon->{description} ]
|
||||
if $addon->{contra} =~ /^[-+]/;
|
||||
|
||||
push @contras, [
|
||||
$addon->{contra},
|
||||
$addon->{price},
|
||||
"$addon->{description} ($contra_desc)"
|
||||
];
|
||||
|
||||
push @addons, @{ $addon->{addons} };
|
||||
}
|
||||
|
||||
my $entry = $cart->add(
|
||||
-$total,
|
||||
$product->{description},
|
||||
{ product_id => $product->{id}, plugin => $self->id, addons => $product->{addons} }
|
||||
);
|
||||
$entry->add_contra(
|
||||
$product->{contra},
|
||||
+$price,
|
||||
$contra_desc
|
||||
);
|
||||
$entry->add_info(@$_) for @infos;
|
||||
$entry->add_contra(@$_) for @contras;
|
||||
|
||||
return ACCEPT;
|
||||
}
|
||||
|
||||
|
|
87
plugins/products.pod
Normal file
87
plugins/products.pod
Normal file
|
@ -0,0 +1,87 @@
|
|||
=head1 NAME
|
||||
|
||||
products - RevBank plugin for selling products
|
||||
|
||||
=head1 SYNOPISIS
|
||||
|
||||
8710447032756 0.80 Festini Peer
|
||||
4029764001807,clubmate 1.40 Club-Mate +pf
|
||||
pf 0.15@+pfand Pfand NRW-Flasche
|
||||
|
||||
=head1 DESCRIPTION
|
||||
|
||||
This plugin turns products from a product list into RevBank commands.
|
||||
|
||||
The configuration for this plugin lives in a text file called
|
||||
C<revbank.products>, which has whitespace-separated columns:
|
||||
|
||||
=head2 Product ids
|
||||
|
||||
One or more product ids, separated by commas (no whitespace before or after the
|
||||
commas). There is no way to have a comma or whitespace in a product id, but
|
||||
every other printable character is valid.
|
||||
|
||||
The first product id on the line is considered canonical, the rest are aliases.
|
||||
|
||||
Note: if a product id is the same as another RevBank command (e.g. a username),
|
||||
the first plugin that accepts the command will "win"; the precedence order is
|
||||
defined by the C<revbank.plugins> configuration file. However, when a product
|
||||
id appears multiple times within C<revbank.products>, the I<last> one is used.
|
||||
|
||||
Product ids that begin with C<+> can only be used as addons. When entered as
|
||||
user input, it will be ignored by the C<products> plugin.
|
||||
|
||||
=head2 Price
|
||||
|
||||
The price of the product. This is the price to be deducted from the user's
|
||||
account when they check out with this product in the cart. When it is a
|
||||
negative number, the user will instead have money added to their account when
|
||||
"buying" this product.
|
||||
|
||||
Optionally, the price can be augmented with at C<@> sign and the name of the
|
||||
contra account. When no contra account is specified, C<+sales/products> is used.
|
||||
Internal accounts (that start with C<-> or C<+>) are created automatically. A
|
||||
regular account can also be used, but has to exist before the product can be
|
||||
used.
|
||||
|
||||
(Note on internal accounts because they aren't documented elsewhere: liability
|
||||
and revenue accounts begin with C<+>, asset and expense accounts begin with
|
||||
C<->. The C<+> accounts typically grow larger over time, while C<-> accounts
|
||||
typically go negative. In general, you would use a C<+> account in
|
||||
C<revbank.products>. User accounts are liability accounts.)
|
||||
|
||||
=head2 Description
|
||||
|
||||
The description may contain whitespace.
|
||||
|
||||
=head2 Addons
|
||||
|
||||
Addons are products that are added as part of the main product. They are
|
||||
specified after the description, with a C<+> sign that has whitespace before
|
||||
it, and no whitespace after it.
|
||||
|
||||
When specifying an addon C<+foo>, and no product with the id C<+foo> exists,
|
||||
the product id C<foo> is used instead. The difference is that a product id
|
||||
C<+foo> can only be used as an addon for another product, while C<foo> can be
|
||||
used either as an addon or a manually entered as a standalone product.
|
||||
|
||||
example_id 2.20 Example product +first +second
|
||||
+first 1.20 First thing
|
||||
second 0.80 Second thing
|
||||
|
||||
In this example, the final price of the example product will be 4.20. It is not
|
||||
possible to buy the first thing separate, but it is possible to buy the second
|
||||
thing separate.
|
||||
|
||||
The addon product must be specified in C<revbank.products>; market products
|
||||
cannot be used as addons.
|
||||
|
||||
When a product has addons, it becomes a compound product. This can be used to
|
||||
separate a product into individual counter accounts for bookkeeping purposes,
|
||||
to add a bottle deposit, or to add other additional fees or discounts.
|
||||
|
||||
When a compound product has a bare price that isn't 0.00, the bare price is
|
||||
listed as a component named "Product".
|
||||
|
||||
A product can have multiple addons. Addon products themselves can also have
|
||||
further addons, but circular recursion is not supported.
|
2
revbank
2
revbank
|
@ -18,7 +18,7 @@ use RevBank::Global;
|
|||
use RevBank::Messages;
|
||||
use RevBank::Cart;
|
||||
|
||||
our $VERSION = "3.5";
|
||||
our $VERSION = "3.6";
|
||||
our %HELP1 = (
|
||||
"abort" => "Abort the current transaction",
|
||||
);
|
||||
|
|
|
@ -19,6 +19,7 @@ stock
|
|||
unlisted
|
||||
#warnings
|
||||
adduser
|
||||
edit
|
||||
|
||||
beep_terminal
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue